Topic: mcp server file path security
MCP server file path security — path.resolve + startsWith, symlink resolution, zip-slip prevention
File path handling is the single most common critical vulnerability class in MCP servers that expose filesystem tools. An MCP tool that accepts a user-controlled path and opens the file at that path is, without explicit containment logic, a directory traversal vulnerability: the model or its prompt can pass ../../etc/passwd or an absolute path like /home/user/.ssh/id_rsa and receive the contents. Five patterns cover the full surface: resolve-then-check with a trailing separator, symlink resolution before the containment test, zip-slip prevention in archive extraction, sanitizing control characters in filenames, and rejecting content-type decisions based on file extension alone.
1. path.resolve + startsWith to prevent directory traversal
The canonical directory traversal fix in Node.js is to resolve the user-supplied path to an absolute path with path.resolve(), then test whether that absolute path starts with the allowed root directory. path.resolve() collapses all ../ segments, so path.resolve('/allowed/root', '../../etc/passwd') returns /etc/passwd, not a path under /allowed/root. The startsWith check then rejects it.
The critical detail that most implementations get wrong is the trailing separator. Without it, a root of /allowed/root passes the check for both /allowed/root/file.txt (intended) and /allowed/root-other/secret.txt (not intended), because both strings start with the prefix /allowed/root:
import path from 'node:path';
import fs from 'node:fs/promises';
const ALLOWED_ROOT = path.resolve('/var/mcp-data');
// UNSAFE: missing trailing separator allows /var/mcp-data-evil/...
function unsafeCheck(userPath: string): boolean {
const resolved = path.resolve(ALLOWED_ROOT, userPath);
return resolved.startsWith(ALLOWED_ROOT); // BUG
}
// SAFE: trailing path.sep ensures the resolved path is actually inside the directory
function safeContainmentCheck(userPath: string): string {
const resolved = path.resolve(ALLOWED_ROOT, userPath);
const root = ALLOWED_ROOT.endsWith(path.sep)
? ALLOWED_ROOT
: ALLOWED_ROOT + path.sep;
// Also allow exact match on the root itself (no trailing sep needed then)
if (resolved !== ALLOWED_ROOT && !resolved.startsWith(root)) {
throw new Error(`Path traversal detected: ${userPath}`);
}
return resolved;
}
// MCP tool handler
async function readFileTool(args: { path: string }): Promise<string> {
const safePath = safeContainmentCheck(args.path);
const content = await fs.readFile(safePath, 'utf8');
return content;
}
path.join() is not sufficient on its own: it normalizes separators but does not collapse all traversal sequences relative to a root. Always use path.resolve() with the allowed root as the first argument so that relative paths are resolved relative to that root, not the process working directory.
2. Symlink resolution before access checks
The containment check above passes for a path like /var/mcp-data/uploads/link-to-etc if an attacker has previously created a symlink at that path pointing to /etc. The path string itself is within the allowed root, so startsWith returns true — but fs.readFile follows the symlink and reads outside the root. The fix is to resolve symlinks to their real path with fs.realpath() before performing the containment check, not after.
Note that fs.realpath() requires the path to exist — it stat-calls each component. For paths that may not yet exist (e.g., a write target), resolve the parent directory and append the sanitized filename:
import path from 'node:path';
import fs from 'node:fs/promises';
const ALLOWED_ROOT = path.resolve('/var/mcp-data');
const ROOT_WITH_SEP = ALLOWED_ROOT + path.sep;
async function safeReadFile(userPath: string): Promise<string> {
// Step 1: resolve path segments (collapses ../ etc.)
const normalized = path.resolve(ALLOWED_ROOT, userPath);
// Step 2: preliminary startsWith check to fail fast before the stat call
if (normalized !== ALLOWED_ROOT && !normalized.startsWith(ROOT_WITH_SEP)) {
throw new Error('Path traversal detected');
}
// Step 3: resolve symlinks to their real target
let real: string;
try {
real = await fs.realpath(normalized);
} catch {
throw new Error('Path does not exist or is not accessible');
}
// Step 4: re-check the REAL path (symlinks now resolved)
if (real !== ALLOWED_ROOT && !real.startsWith(ROOT_WITH_SEP)) {
throw new Error('Symlink escape detected');
}
return fs.readFile(real, 'utf8');
}
// For write targets where the file doesn't exist yet:
async function safeWriteFile(userPath: string, content: string): Promise<void> {
const normalized = path.resolve(ALLOWED_ROOT, userPath);
const dir = path.dirname(normalized);
const basename = path.basename(normalized);
// Resolve parent directory (must exist) and recheck
const realDir = await fs.realpath(dir);
if (realDir !== ALLOWED_ROOT && !realDir.startsWith(ROOT_WITH_SEP)) {
throw new Error('Symlink escape in parent directory');
}
await fs.writeFile(path.join(realDir, basename), content, 'utf8');
}
In environments where O_NOFOLLOW is available (Linux, macOS), you can open the file with that flag to prevent the kernel from following the final symlink component at the filesystem level — providing defense-in-depth beyond the userspace check.
3. Zip-slip prevention in archive extraction
Zip-slip is a variant of directory traversal specific to archive extraction. When an MCP tool accepts a user-uploaded tar or zip file and extracts it, each entry's stored path is attacker-controlled. An entry with a path like ../../../../home/user/.bashrc or an absolute path like /etc/cron.d/backdoor will be written outside the extraction root if the extraction code does not validate each entry path before writing.
The fix is identical to the general containment check — resolve each entry path against the extraction root and verify the result — but it must be applied to every entry in the archive, not just the archive name itself. Using the yauzl library for zip files:
import path from 'node:path';
import fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import yauzl from 'yauzl';
const MAX_ENTRY_SIZE = 50 * 1024 * 1024; // 50 MB per entry
async function safeExtractZip(
zipPath: string,
extractRoot: string
): Promise<void> {
const root = path.resolve(extractRoot);
const rootWithSep = root + path.sep;
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err || !zipfile) return reject(err ?? new Error('Failed to open zip'));
zipfile.readEntry();
zipfile.on('entry', (entry) => {
// Zip-slip check: resolve the entry name against the root
const entryPath = path.resolve(root, entry.fileName);
// Reject any entry that escapes the extraction root
if (entryPath !== root && !entryPath.startsWith(rootWithSep)) {
zipfile.close();
return reject(new Error(`Zip-slip detected: ${entry.fileName}`));
}
// Skip directories — create them explicitly
if (/\/$/.test(entry.fileName)) {
fs.mkdir(entryPath, { recursive: true })
.then(() => zipfile.readEntry())
.catch(reject);
return;
}
// Bomb check: reject entries claiming to be very large
if (entry.uncompressedSize > MAX_ENTRY_SIZE) {
zipfile.close();
return reject(new Error(`Entry too large: ${entry.fileName}`));
}
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) return reject(streamErr);
fs.mkdir(path.dirname(entryPath), { recursive: true }).then(() => {
const out = createWriteStream(entryPath);
readStream.pipe(out);
out.on('close', () => zipfile.readEntry());
out.on('error', reject);
}).catch(reject);
});
});
zipfile.on('end', resolve);
zipfile.on('error', reject);
});
});
}
The same pattern applies to tar archives via tar-stream: extract each entry's header name, resolve it against the root, check containment, then pipe the stream to the validated output path. Never pass user-controlled archive paths to shell commands like tar -xzf — use a library that gives you per-entry path control.
4. Filename sanitization to strip control characters
Even when the directory traversal check passes, a user-supplied filename can carry characters that cause problems at the filesystem or application layer. Null bytes (\x00) terminate strings in C-based filesystem code — a filename like safe.txt\x00../../etc/passwd can cause the underlying C library to open ../../etc/passwd while the Node.js string representation looks harmless. Path separators embedded in a filename component (subdir/file.txt) add a directory component that startsWith checks on the parent may not account for. Unicode normalization differences between NFC and NFD can create two filenames that look identical in a browser but are different bytes on disk.
The correct approach is to use path.basename() to strip any directory components, then apply a deny-list regex to remove dangerous characters:
import path from 'node:path';
/**
* Sanitize a user-supplied filename to a safe single component.
* Does NOT validate the directory — combine with the containment check.
*/
function sanitizeFilename(raw: string): string {
// 1. Normalize unicode to NFC (canonical composed form)
// Prevents NFD bypass where accented chars decompose to different bytes
const normalized = raw.normalize('NFC');
// 2. Strip null bytes (C string terminator injection)
const noNull = normalized.replace(/\x00/g, '');
// 3. path.basename strips any directory component the attacker smuggled in
// e.g. "../../secret" becomes "secret"
const base = path.basename(noNull);
// 4. Deny-list: remove remaining dangerous characters
// Covers: control chars (0x00–0x1f, 0x7f), path separators (/ \),
// Windows reserved chars (: * ? " < > |), and leading dots/dashes
const safe = base
.replace(/[\x00-\x1f\x7f]/g, '') // control characters
.replace(/[/\\:*?"<>|]/g, '') // path/shell special chars
.replace(/^[.\-]+/, ''); // strip leading dots and dashes
if (safe.length === 0) {
throw new Error('Filename is empty after sanitization');
}
// 5. Enforce a maximum length to prevent filesystem path length exhaustion
if (safe.length > 255) {
throw new Error('Filename exceeds maximum length');
}
return safe;
}
// Usage in an MCP tool that writes user-named files:
async function writeNamedFile(
dir: string,
rawName: string,
content: Buffer
): Promise<string> {
const safe = sanitizeFilename(rawName);
// Now apply the containment check on the final composed path
const target = path.join(path.resolve(dir), safe);
await fs.writeFile(target, content);
return safe;
}
On case-insensitive filesystems (macOS HFS+, Windows NTFS), two distinct sanitized names like Config.json and config.json refer to the same file. If your MCP server runs on or targets such filesystems, normalize filenames to lowercase before the containment check to avoid confused-deputy overwrites through case variation.
5. Never trust user-controlled file extensions
MCP servers that serve files or pass file content to downstream processors sometimes gate behavior on the file extension: serving only .md files, passing only .py files to the code-execution tool, or rejecting .exe uploads. Extension-based checks are trivially bypassed — an attacker renames malicious.php to notes.md or backdoor.exe to readme.txt. File extension is metadata under the attacker's control.
The correct gate is magic-byte inspection: read the first 4–12 bytes of the actual file content and compare them against known signatures. The file-type npm package does this for hundreds of formats using a declarative byte pattern table:
import { fileTypeFromBuffer } from 'file-type';
import fs from 'node:fs/promises';
import path from 'node:path';
// Formats that should never be served or forwarded from MCP file tools
const BLOCKED_MIME_PREFIXES = [
'application/x-msdownload', // PE executables (.exe, .dll)
'application/x-executable', // ELF executables
'application/x-mach-binary', // macOS Mach-O
'application/x-sharedlib', // shared libraries
'application/x-sh', // shell scripts (magic-byte detectable variants)
];
// Text MIME types allowed for the read_file tool
const ALLOWED_MIME_PREFIXES = [
'text/',
'application/json',
'application/xml',
'application/javascript', // only if the tool intentionally handles JS
];
async function safeServeFile(filePath: string): Promise<{ content: string; mimeType: string }> {
const stat = await fs.stat(filePath);
if (!stat.isFile()) throw new Error('Not a regular file');
// Read only the first 4 KB for magic-byte detection — no need to load the whole file
const fd = await fs.open(filePath, 'r');
const headerBuf = Buffer.alloc(4096);
const { bytesRead } = await fd.read(headerBuf, 0, 4096, 0);
await fd.close();
const header = headerBuf.subarray(0, bytesRead);
const detected = await fileTypeFromBuffer(header);
// If file-type identifies a blocked binary format, reject regardless of extension
if (detected) {
const mime = detected.mime;
if (BLOCKED_MIME_PREFIXES.some(prefix => mime.startsWith(prefix))) {
throw new Error(`Blocked file type detected: ${mime} (extension: ${path.extname(filePath)})`);
}
// If detected and not explicitly allowed, reject
if (!ALLOWED_MIME_PREFIXES.some(prefix => mime.startsWith(prefix))) {
throw new Error(`File type not permitted: ${mime}`);
}
}
// file-type returns undefined for plain text — that's expected for .md, .txt, .json
// Read the full file only after the magic-byte check passes
const content = await fs.readFile(filePath, 'utf8');
return {
content,
mimeType: detected?.mime ?? 'text/plain',
};
}
SkillAudit's Security axis specifically tests for extension-only content-type gating by uploading files with mismatched extensions and observing whether the MCP server passes the content downstream without magic-byte validation. An extension-only gate combined with a downstream code-execution tool is a critical-severity finding. Run a free audit at skillaudit.dev to check whether your MCP file tools perform magic-byte validation before serving or forwarding file content.
Related: access control, API gateway security, security checklist.