@MODELCONTEXTPROTOCOL/SERVER-FILESYSTEM · GHSA-4gxg-m5jx-9j57 · Package
FIX → 2.0.1
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.
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 with a case-insensitive file system (macOS or Windows by default) and an allowed directory that contains symlinks pointing outside it. The advisory reports the issue reproduced on Linux with a case-folded mount; macOS and Windows were not tested. Setups without outward-pointing symlinks in allowed directories are less exposed.
Not affected if case-sensitive-filesystem, no-outward-symlinks
Who cares macos, mcp, node, windows
npm ls @modelcontextprotocol/server-filesystemUpgrade to 2.0.1
@modelcontextprotocol/server-filesystem (affected range )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.
validatePath() first calls on the requested path. For a path that does not exist yet this fails with , and hands over to (). That function starts at the real path of the allowed directory and walks the requested path one component at a time ():
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.
2.0.1>= 1.0.0, < 2.0.12026.8.31 has no fixed version stated in the sourcesfs.realpath()ENOENTvalidatePath()resolveUnicodeEquivalentPath()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.) 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) 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 --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), 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