Skip to content

[GHSA-53mr-w429-c597 follow-up] - filesystem: close the parent-directory swap on Windows #5260

Open

Description

@cliffhall

Follow-up to GHSA-53mr-w429-c597 (#5257), whose fix (#5237, @modelcontextprotocol/server-filesystem 2.0.1) closes the parent-directory swap on Linux and macOS only.

Problem

GHSA-53mr-w429-c597
is fixed on Linux and macOS but not on Windows. On Windows, a directory on
the path of a write_file, edit_file, create_directory or move_file
(destination) call can still be swapped for a junction between validation and
use. The write then lands outside the allowed directories, as it did before the
fix. Creating a junction needs no special privilege.

The fix anchors each write to its directory. It chdirs into the validated
directory, checks process.cwd(), then opens by bare name (src/filesystem/pinned-directory.ts).
That works where getcwd reports the directory the process actually entered.
On Windows it does not, and Node gives this design no handle-relative behaviour
at all.

Why the chdir anchor does nothing on Windows (traced in Node v24.20.0's source)

  1. fs.realpathSync.native and fs.openSync pass the path straight to the C++
    binding (printed from the Node 24 runtime).
  2. src/node_file.cc: RealPath (line 2094) and Open (line 2313) call
    ToNamespacedPath(env, &path) before doing anything else, and the other fs
    bindings do the same.
  3. src/path.cc:282: on Windows, ToNamespacedPath calls PathResolve, which
    prepends env->GetCwd() to a relative path and returns an absolute
    \\?\C:\… string.
  4. src/env.cc:709 → libuv uv_cwd → GetCurrentDirectoryW returns the cwd
    as a string: whatever uv_chdir passed to SetCurrentDirectoryW.
  5. libuv fs__realpath (deps/uv/src/win/fs.c) calls CreateFileW on that
    absolute string, then GetFinalPathNameByHandleW.

So every "relative" open, mkdir, rename and realpath that Node makes on
Windows is re-resolved from the cwd path string. None of them goes through the
directory handle Windows holds for the cwd. A check such as
fs.realpathSync.native('.') after chdir reports where the string points
now, so an attacker who swaps the junction back passes it.

Options

A. Check the cwd with realpathSync.native('.'): ruled out

This fails for the reason above.

B. Lock every directory on the path

Walk from the allowed root to the target. Hold each directory open so it cannot
be renamed or replaced. Check the chain only once it is locked, then operate
and release.

  • Sound under documented Windows semantics.
  • libuv only offers two share modes. It opens with all sharing modes
    (FILE_SHARE_READ | WRITE | DELETE) unless UV_FS_O_EXLOCK is passed, in
    which case it shares nothing (deps/uv/src/win/fs.c:501-509). There is no
    "deny delete only" mode.
  • Writes would be refused at random. The locks must be fully exclusive, so
    a write fails whenever Explorer, an editor, an indexer or antivirus has any
    directory on the path open.
  • Unverified: whether Node passes UV_FS_O_EXLOCK through.
    fs.constants does not expose it.

C. Open relative to a directory handle (the real fix)

Windows' equivalent of openat is NtCreateFile/NtOpenFile with
OBJECT_ATTRIBUTES.RootDirectory set to a directory handle. Win32 does not
expose it. Rust's standard library used this to fix the same class of bug,
CVE-2022-21658 in remove_dir_all: see open_link_no_reparse in
library/std/src/sys/fs/windows/remove_dir_all.rs. That function sets
RootDirectory to the parent handle and adds FILE_OPEN_REPARSE_POINT. It
also sets OBJ_DONT_REPARSE, retrying without it on INVALID_PARAMETER for
older Windows.

Design for this server:

  1. Open the allowed root as a directory handle.
  2. Walk down one component at a time. Open each component relative to the
    previous handle with FILE_OPEN_REPARSE_POINT | FILE_DIRECTORY_FILE, and
    refuse it if it carries FILE_ATTRIBUTE_REPARSE_POINT (a junction or
    symlink). Swapping a component cannot redirect the walk, because it follows
    handles, not strings.
  3. write_file (create): NtCreateFile(FILE_CREATE) relative to the
    parent handle.
  4. Overwrite and edit_file: FILE_OPEN with FILE_OPEN_REPARSE_POINT,
    then check that the opened file is not a reparse point.
  5. create_directory: NtCreateFile(FILE_DIRECTORY_FILE | FILE_CREATE) at
    each missing level, relative to the previous handle.
  6. move_file (destination): NtSetInformationFile with
    FileRenameInformation, whose RootDirectory is the destination
    directory's handle.
  7. Writing content: either through the native handle, or by turning it
    into a Node fd with ucrt's _open_osfhandle. That route is plausible but
    unverified: Node's fs on Windows maps fds through the CRT.

Known catches:

  • FAT and exFAT reject FILE_OPEN_REPARSE_POINT with INVALID_PARAMETER, so
    retry without it (see ReactOS).
  • OBJ_DONT_REPARSE exists only on newer Windows.
  • Wine historically rejected a non-null RootDirectory.

Three ways to ship option C:

How Works on Node 24 (the engines floor)? Cost
C1. Built-in node:ffi Call ntdll.dll from JS No. Added in v26.1.0 and experimental. Checked on macOS: Node 24.20 has no node:ffi; Node 26.7.0 loads it only with --experimental-ffi (newer 26.x docs say it can be disabled with --no-experimental-ffi). No struct type, so OBJECT_ATTRIBUTES, UNICODE_STRING and IO_STATUS_BLOCK are hand-built in Buffers. A wrong signature crashes the process. Passing a flag to an npx bin on Windows is fragile. Restricted under the permission model unless --allow-ffi is set.
C2. Third-party FFI (e.g. koffi) Prebuilt FFI with struct support, as a win32-only optional dependency Yes Quickest to build, but puts a third-party native binary inside an official reference package. A supply-chain decision.
C3. Own N-API addon Roughly 200 lines of C, prebuilt for win32-x64 and win32-arm64, shipped as platform packages Yes Most auditable. Needs a Windows build job (run outside the credentialed jobs, under the pinning rules in AGENTS.md), pack:verify on Windows, prebuilds per supported Node ABI, and RELEASING.md changes.

Recommendation

  • Track C1. Once node:ffi is stable and on by default, and engines can
    move to that Node version, it closes Windows with no native dependency.
  • If Windows cannot wait for that, choose between C2 (fast, but trusts a
    third-party binary) and C3 (owned, but costs release-pipeline work). That is
    a maintainer policy decision, not an engineering one.
  • Do not adopt B unless the random refusals are acceptable.

Acceptance

  • On Windows, swapping a directory on the path for a junction between
    validation and use is refused for write_file (create and overwrite),
    edit_file, create_directory and the move_file destination. Nothing
    lands outside, and no outside file is modified.
  • The regression tests stage the swap deterministically and run on the
    windows-latest leg of typescript.yml. They fail without the fix.
  • Junctions and symlinks that resolve inside the allowed directories
    keep working.
  • FAT/exFAT volumes and older Windows releases still work, through the
    flag-retry paths.
  • Linux and macOS behaviour is unchanged.
  • Per-file coverage of at least 90 on every dimension.
  • The advisory's text is updated to say Windows is fixed, with the version
    that fixes it.

References

Activity

  1. added this to the v2.2.0 milestone on Oct 25, 2026
  2. added
    bugSomething isn't working
    server-filesystemReference implementation for the Filesystem MCP server - src/filesystem
    on Oct 25, 2026
  3. youlemei2022-web commented on Oct 27, 2026

    @youlemei2022-web

    Thanks for writing this up - the diagnosis matches what we see on Windows.

    Two additions from someone running a Windows desktop agent that hands filesystem access to MCP tools:

    1. getcwd-based pinning has no Windows analogue. On Windows the process has no per-directory cwd handle to compare against, so process.cwd() returns a path string that may already be stale by the time you compare it. The Linux/macOS fix cannot be ported by changing a constant.

    2. Three options, cheapest first.

    • Reject, don't follow. Walk every component with lstatSync and refuse entries where isSymbolicLink() is true (junctions and symlinks both report as symlinks on Windows), or where the resolved path escapes the configured root. This closes the common case, but it is still check-then-use - a swap between the lstat and the open wins.
    • Pin by handle, then go relative. Open the parent directory with FILE_FLAG_BACKUP_SEMANTICS and perform the create/write with NtCreateFile using that handle as RootDirectory and a single-component name. This is the only variant that is actually race-free on Windows. Node has no handle-relative open, so it needs a small native binding or a helper process that does the write itself.
    • Verify after the fact. Open the file, then compare GetFinalPathNameByHandle with the expected canonical path. Same native-binding cost as the previous option, but a far smaller change, and it converts a silent escape into a loud failure.

    If you ship the first option as the interim fix, the test that matters is not "junction rejected" - it is "swap between validate and open". A small harness that flips the parent directory to a junction in a loop (via fs.watch or just a tight timer) while a writer runs reproduces it within seconds on NTFS, and it will also tell you whether the handle-relative fix actually closed the window.

    Disclosure: I build MOOGH, a Windows desktop agent that exposes MCP filesystem tools, so this failure mode is not hypothetical for us. No link - happy to share the harness if it is useful.

  4. cliffhall commented on Oct 27, 2026

    @cliffhall
    MemberAuthor

    Deferred to v2.3.0.

    A race-free fix on Windows needs to open files relative to a directory handle (option C). Node can't do that without native code. Choosing how to ship that native code (built-in node:ffi once it's stable, a third-party FFI, or our own addon) is a maintainer policy decision that isn't settled. It won't be decided in time for v2.2.0.

    We also considered an interim lstat walk just before each write and decided against it. It narrows the race but doesn't close it, it can't pass this issue's acceptance test, and it would refuse legitimate junctions inside the allowed directories.

    Until this is fixed, Linux and macOS stay protected by the fix in #5237, and the advisory still says Windows isn't covered.

  5. modified the milestones: v2.2.0, v2.3.0 on Oct 27, 2026
  6. youlemei2022-web commented on Oct 28, 2026

    @youlemei2022-web

    Thanks for the triage - deferring to 2.3.0 makes sense given that a race-free path needs handle-relative opens and Node gives us nothing there.

    If it is useful before then: the reproduction harness is small enough to live as a test fixture. It flips the parent directory to a junction on a short timer while a writer runs, which reproduces the escape within seconds on NTFS, and it also tells you whether a candidate fix actually closes the window. Happy to put it in a comment or a PR against the test directory - say which you prefer and I will do that rather than guess.

    No other action needed from my side.

  7. modified the milestones: v2.3.0, v2.4.0 on Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingserver-filesystemReference implementation for the Filesystem MCP server - src/filesystemv2

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions