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
includes('../') or startsWith('/') checks without resolving the path first. Bypassed by URL encoding, double-dot normalization, or Unicode sequences. Grade impact: −18.
Related: MCP server input validation patterns · Zero-day incident timeline (path traversal) · Template injection security