Input Validation · Path Traversal · File Security

MCP server path traversal bypass security

MCP servers with file-reading tools frequently add a check for ../ in the path argument to prevent directory traversal. This check fails against six bypass techniques that are trivial to apply: URL encoding, double encoding, null byte injection, Unicode normalization, symlinks, and Windows path variants. The safe pattern is not a string check — it is a path.resolve() containment assertion after all normalization.

Why string-based path checks fail

The naive check looks like this:

// VULNERABLE — string check that misses all encoding bypass variants
async function handleReadFile(req) {
  const { path: filePath } = req.params;
  if (filePath.includes('../') || filePath.includes('..\\')) {
    throw new ToolError('INVALID_INPUT', 'Path traversal not allowed');
  }
  return fs.readFile(filePath, 'utf8');
}

This check is defeated by any encoding that the Node.js filesystem layer decodes after the check runs but before the file is opened. Here are the bypass techniques:

Bypass technique 1: URL encoding

// The string check looks for "../" — URL encoding bypasses it
// %2e = .   %2f = /   %5c = \
const payload = '%2e%2e%2f%2e%2e%2fetc%2fpasswd';
// After decodeURIComponent: ../../etc/passwd
// The includes('../') check sees %2e%2e%2f — no match
// If filePath is decoded before fs.readFile, the traversal succeeds

If the MCP server framework or any middleware calls decodeURIComponent() on path arguments (common in web-framework-based MCP servers), the check happens on the encoded form but the file open happens on the decoded form.

Bypass technique 2: double encoding

// First encoding: . → %2e, / → %2f
// Double encoding: % → %25, so %2e → %252e, %2f → %252f
// Payload: %252e%252e%252f%252e%252e%252fetc%252fpasswd
// After first decodeURIComponent: %2e%2e%2f%2e%2e%2fetc%2fpasswd
// If decoded a second time (double-decode bug): ../../etc/passwd

Bypass technique 3: null byte injection

// On some systems, a null byte truncates the path string at the OS level
// Many older C-based implementations used to stop at \0
// In Node.js, path arguments with \0 throw ERR_INVALID_ARG_VALUE
// But in some string-processing contexts, \0 can split the path check
const payload = '../../../etc/passwd\x00.txt';
// Check: does payload.includes('../')? YES — this variant does not bypass Node.js
// But in Python/Perl/PHP backends behind an MCP server proxy, it can

// The Node.js fs module rejects null bytes explicitly, so this variant
// matters more for MCP servers backed by non-Node.js file services

Bypass technique 4: Unicode normalization (NFKC/NFC)

// Unicode has multiple representations for "/"
// U+2215 DIVISION SLASH (∕) normalizes to "/" under NFKC
// U+FF0F FULLWIDTH SOLIDUS (/) normalizes to "/" under NFKC
// U+FE68 SMALL REVERSE SOLIDUS (﹨) normalizes to "\" under NFKC

// Bypass:
const payload = '..∕..∕etc∕passwd';  // uses U+2215 instead of /
// payload.includes('../') → false (no ASCII /)
// After normalizeCase or NFKC normalization → ../../etc/passwd

// Relevant in servers that apply .normalize('NFKC') for display purposes
// after the security check

Bypass technique 5: symlinks outside the allowed root

// If the allowed directory /data/uploads/ contains a symlink:
// /data/uploads/secret -> /etc/passwd
// Then path '/data/uploads/secret' passes the containment check
// (it starts with /data/uploads/) but reads /etc/passwd

// The containment check on the resolved path must use
// fs.realpath() to resolve symlinks before comparing:
const realPath = await fs.promises.realpath(absolutePath);
// NOT path.resolve() alone — path.resolve does NOT follow symlinks

The safe pattern: path.resolve() containment with symlink resolution

import path from 'node:path';
import fs from 'node:fs/promises';

const ALLOWED_ROOT = path.resolve('/data/uploads');

async function safeReadFile(rawPath) {
  // 1. Reject null bytes explicitly
  if (rawPath.includes('\x00')) {
    throw new ToolError('INVALID_INPUT', 'Invalid path');
  }

  // 2. Decode any URL encoding (normalize before checking)
  let decodedPath;
  try {
    decodedPath = decodeURIComponent(rawPath);
  } catch {
    throw new ToolError('INVALID_INPUT', 'Invalid path encoding');
  }

  // 3. Normalize Unicode sequences
  const normalizedPath = decodedPath.normalize('NFKC');

  // 4. Resolve to an absolute path (resolves .., multiple slashes, etc.)
  const absolutePath = path.resolve(ALLOWED_ROOT, normalizedPath);

  // 5. Check containment using the resolved absolute path (no symlinks yet)
  if (!absolutePath.startsWith(ALLOWED_ROOT + path.sep) &&
      absolutePath !== ALLOWED_ROOT) {
    throw new ToolError('INVALID_INPUT', 'Path outside allowed directory');
  }

  // 6. Resolve symlinks and re-check containment
  let realPath;
  try {
    realPath = await fs.realpath(absolutePath);
  } catch {
    throw new ToolError('NOT_FOUND', 'File not found');
  }

  if (!realPath.startsWith(ALLOWED_ROOT + path.sep) &&
      realPath !== ALLOWED_ROOT) {
    throw new ToolError('INVALID_INPUT', 'Path resolves outside allowed directory');
  }

  return fs.readFile(realPath, 'utf8');
}

This six-step pattern defeats all the bypass techniques above: null byte rejection (step 1), URL encoding normalization (step 2), Unicode normalization (step 3), double-dot resolution (step 4), string containment on the resolved path (step 5), and symlink resolution before the final containment check (step 6). Do not skip step 6 — the SkillAudit zero-day incident post shows a real-world exploitation that passed step 5 via symlink.

Zod schema integration for path inputs

import { z } from 'zod';
import path from 'node:path';

const ALLOWED_ROOT = path.resolve('/data/uploads');

const FilePathSchema = z
  .string()
  .min(1)
  .max(1024)  // prevent absurdly long paths
  .refine(p => !p.includes('\x00'), 'Null bytes not allowed')
  .transform(p => decodeURIComponent(p).normalize('NFKC'))
  .refine(p => {
    const abs = path.resolve(ALLOWED_ROOT, p);
    return abs.startsWith(ALLOWED_ROOT + path.sep) || abs === ALLOWED_ROOT;
  }, 'Path outside allowed directory');

// Use in tool schema
server.tool('read_file', {
  inputSchema: z.object({ path: FilePathSchema }),
  handler: async ({ path: filePath }) => {
    // filePath is now decoded and normalized — still check realpath for symlinks
    const abs = path.resolve(ALLOWED_ROOT, filePath);
    const real = await fs.realpath(abs);
    if (!real.startsWith(ALLOWED_ROOT)) throw new ToolError('INVALID_INPUT', 'Symlink escape');
    return fs.readFile(real, 'utf8');
  }
});

SkillAudit findings for path traversal patterns

CRITICAL path.join() or direct string concatenation used for file access with user input, no containment check. Any path the caller provides is used directly. Complete filesystem read access. Grade impact: −25.
HIGH String-based traversal check without path.resolve() normalization. includes('../') or startsWith('/') checks without resolving the path first. Bypassed by URL encoding, double-dot normalization, or Unicode sequences. Grade impact: −18.
MEDIUM path.resolve() containment check without fs.realpath() symlink resolution. Containment check passes on the unresolved path but symlinks in the allowed directory can point outside it. Grade impact: −8.

Related: MCP server input validation patterns · Zero-day incident timeline (path traversal) · Template injection security