---
id: "GHSA-4gxg-m5jx-9j57"
page: "https://infrastack.ai/live/GHSA-4gxg-m5jx-9j57"
kind: "vulnerability"
aliases: ["GHSA-4gxg-m5jx-9j57"]
title: "Case-variant path bypasses symlink check and allows creating files outside allowed directories in server-filesystem"
severity: "medium"
cvss: 5.3
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@modelcontextprotocol/server-filesystem", "fixed": [], "affected": "= 2026.8.31", "ecosystem": "npm"}, {"name": "@modelcontextprotocol/server-filesystem", "fixed": ["2.0.1"], "affected": ">= 1.0.0, < 2.0.1", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-178", "cwe-59", "macos", "mcp", "node", "package", "windows"]
not_affected_if: ["case-sensitive-filesystem", "no-outward-symlinks"]
published_at: "2026-10-25T00:18:21+00:00"
first_seen_at: "2026-11-09T22:53:25+00:00"
updated_at: "2026-11-09T22:56:45+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-4gxg-m5jx-9j57", "first_seen_at": "2026-11-09T22:53:25+00:00"}]
exploit_sources: []
---

# Case-variant path bypasses symlink check and allows creating files outside allowed directories in server-filesystem

Link for people: https://infrastack.ai/live/GHSA-4gxg-m5jx-9j57 (this .md is for agents)

On case-insensitive file systems, @modelcontextprotocol/server-filesystem can create files and directories outside its allowed directories when an allowed directory holds a symlink and the request spells its name with different letter case. An attacker can create new files and directories outside the allowed area, but existing outside files stay protected.

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

## Affected

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

## How to check

- Check the installed version with `npm ls @modelcontextprotocol/server-filesystem`
- Check whether any allowed directory contains symlinks pointing outside it, and whether the file system is case-insensitive (macOS APFS or Windows NTFS by default)

## Fix

- `@modelcontextprotocol/server-filesystem` `2.0.1` (affected range `>= 1.0.0, < 2.0.1`)
- Version `2026.8.31` has no fixed version stated in the sources

## Timeline

- 2026-11-09T22:53:25+00:00 ghsa-repo https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-4gxg-m5jx-9j57

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-4gxg-m5jx-9j57

## From the sources

### Summary

On a case-insensitive file system, `server-filesystem` 2026.8.31, 1.0.0 and 2.0.0 create files and directories outside the allowed directories through `create_directory`, `write_file` and the destination of `move_file`, if an allowed directory contains a symlink that points out and the request spells the symlink's name with different letter case. `resolveUnicodeEquivalentPath()` compares path components with `fs.readdir()` entries as strings, so `LINK` does not match `link`; it returns the rest of the path without a `realpath()`, and the OS follows the symlink when it creates the file.

macOS (APFS) and Windows (NTFS) are case-insensitive by default. I reproduced this on Linux with a case-folded tmpfs and have not run it on macOS or Windows. Only new files and directories can be created, existing files outside stay protected. It is a regression: 2026.7.10 refuses these requests.

### Details

`validatePath()` first calls `fs.realpath()` on the requested path. For a path that does not exist yet this fails with `ENOENT`, and `validatePath()` hands over to `resolveUnicodeEquivalentPath()` ([lib.ts#L256-L258](https://github.com/modelcontextprotocol/servers/blob/51b6382a3e827aceb6bce430b1208d69b93d95ea/src/filesystem/lib.ts#L256-L258)). That function starts at the real path of the allowed directory and walks the requested path one component at a time ([lib.ts#L157-L191](https://github.com/modelcontextprotocol/servers/blob/51b6382a3e827aceb6bce430b1208d69b93d95ea/src/filesystem/lib.ts#L157-L191)):

```ts
const entries = (await fs.readdir(currentPath)) ?? [];
const exactMatch = entries.find((entry) => entry === requestedPart);
const equivalentMatches = exactMatch
  ? [exactMatch]
  : entries.filter(
      (entry) => unicodeMatchKey(entry) === unicodeMatchKey(requestedPart),
    );
...
if (equivalentMatches.length === 0) {
  // Nothing below this point exists yet, so there are no symlinks left to
  // resolve. ...
  return path.join(currentPath, ...relativeParts.slice(index));
}
```

([lib.ts#L159-L176](https://github.com/modelcontextprotocol/servers/blob/51b6382a3e827aceb6bce430b1208d69b93d95ea/src/filesystem/lib.ts#L159-L176).) For an entry that matches, the loop runs `fs.realpath()` and checks the result against the allowed directories. The matching is a string comparison: `unicodeMatchKey` ([lib.ts#L100-L102](https://github.com/modelcontextprotocol/servers/blob/51b6382a3e827aceb6bce430b1208d69b93d95ea/src/filesystem/lib.ts#L100-L102)) normalizes to NFC and maps Unicode spaces to U+0020 but keeps letter case. So with a symlink `link -> /outside` in the allowed directory, a request for `LINK/a` finds no entry called `LINK`, the function returns `<allowed>/LINK/a` unchecked, and on a case-insensitive file system `mkdir` or `open` resolves `LINK` through `link`.

The case-varied component does not have to be the symlink. In `SUB/ln2/x`, with `sub/ln2 -> /outside`, the walk stops at `SUB` and never looks at `ln2`, even though `ln2` is spelled exactly. So a fix that checks only the first missing component is not enough.

History: before 2026.8.31 `validatePath()` ran `fs.realpath()` on the parent directory, so the OS did the case folding and `LINK/a` was refused. PR #4638 replaced that with the `readdir` walk; there, an unmatched component that was not the last one still raised "Parent directory does not exist". PR #4631 (issue #4629), merged on top of it, changed that branch to return the unchecked rest of the path so that `create_directory` can create nested directories. Both first shipped in 2026.8.31. The existing symlink test (`refuses a new path under a symlinked directory that leaves (#4629)`) uses the exact spelling only. The class is the one in GHSA-q66q-fx2p-7w4m (CVE-2025-53109): reaching files outside the allowed directories through symlinks inside them.

Suggested fix: when no entry matches by spelling, pass the requested name to `fs.realpath()` as it is, so the OS applies whatever case folding or aliasing the file system has, and check containment at every step. Only a name that neither resolves nor exists under `lstat` is treated as the start of the missing tail. A dangling symlink still fails like it does today. I have this as a patch with tests (the `lib.ts` change is below, the tests are in `server-paths.test.ts`) and can open a PR against the private fork:

```diff
diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts
index abb6b09..0a52573 100644
--- a/src/filesystem/lib.ts
+++ b/src/filesystem/lib.ts
@@ -133,6 +133,18 @@ function respellAllowedDirectoryPrefix(
   return absolutePath;
 }
 
+// Whether a directory entry of this name exists, without following it: a
+// dangling symlink does, a name nothing was created under does not.
+async function entryExists(entryPath: string): Promise<boolean> {
+  try {
+    await fs.lstat(entryPath);
+    return true;
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
+    throw error;
+  }
+}
+
 async function resolveUnicodeEquivalentPath(
   absolutePath: string,
   allowedDirectories: readonly string[],
@@ -168,16 +180,33 @@ async function resolveUnicodeEquivalentPath(
       throw new Error(`Ambiguous Unicode path component: ${requestedPart}`);
     }
 
-    if (equivalentMatches.length === 0) {
-      // Nothing below this point exists yet, so there are no symlinks left to
-      // resolve. currentPath is already realpath'd and inside an allowed
-      // directory; append the missing tail so create_directory can mkdir -p it.
-      return path.join(currentPath, ...relativeParts.slice(index));
-    }
-
-    currentPath = await fs.realpath(
-      path.join(currentPath, equivalentMatches[0]),
+    // An entry whose name matches by spelling is looked up by that name. When
+    // none does, the requested name is still handed to the OS as it is: on a
+    // case-insensitive file system (macOS APFS/HFS+ and Windows NTFS by
+    // default) it can name an existing entry spelled with other letter case,
+    // and that entry can be a symlink that leaves the allowed directories.
+    const candidate = path.join(
+      currentPath,
+      equivalentMatches[0] ?? requestedPart,
     );
+    try {
+      currentPath = await fs.realpath(candidate);
+    } catch (error) {
+      if (
+        equivalentMatches.length === 0 &&
+        (error as NodeJS.ErrnoException).code === "ENOENT" &&
+        !(await entryExists(candidate))
+      ) {
+        // Nothing below this point exists yet, so there are no symlinks left
+        // to resolve. currentPath is already realpath'd and inside an allowed
+        // directory; append the missing tail so create_directory can mkdir -p
+        // it.
+        return path.join(currentPath, ...relativeParts.slice(index));
+      }
+      // A dangling symlink also fails realpath with ENOENT; it is reported
+      // like any other failure rather than treated as a missing name.
+      throw error;
+    }
     if (
       !isPathWithinAllowedDirectories(
         normalizePath(currentPath),
```

Three of the four new tests in `server-paths.test.ts` skip themselves on a case-sensitive file system; the fourth checks that there a differently-cased name is just a new directory. I have not run the patch on macOS or Windows; it relies on `fs.realpath()` following a differently-cased symlink name there, as it does on the casefolded mount.

One related hardening note, not a separate claim: the first step of the same function, `fs.realpath(allowedDirectory)` ([lib.ts#L151](https://github.com/modelcontextprotocol/servers/blob/51b6382a3e827aceb6bce430b1208d69b93d95ea/src/filesystem/lib.ts#L151)), is not checked against the allow-list. If an allowed directory itself is swapped for a symlink after the server started, new paths below it land in the symlink's target; that needs write access to the directory's parent, so I rate

---

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