[GHSA-53mr-w429-c597 follow-up] - filesystem: close the parent-directory swap on Windows
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 25/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- github-actions, node.js, typescript
調査の方向性
Start with src/filesystem/pinned-directory.ts and the Windows filesystem behavior traced in the issue. Review the listed design options and acceptance criteria; implementation requires choosing a native interface and may involve release pipeline work in AGENTS.md and RELEASING.md. Done means the Windows junction swap regression cases pass on the windows-latest leg of typescript.yml, the other listed filesystem behaviors remain covered, and the advisory is updated.
索引モデルが issue の本文から書いたものです。
説明
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)
fs.realpathSync.nativeandfs.openSyncpass the path straight to the C++
binding (printed from the Node 24 runtime).src/node_file.cc:RealPath(line 2094) andOpen(line 2313) call
ToNamespacedPath(env, &path)before doing anything else, and the other fs
bindings do the same.src/path.cc:282: on Windows,ToNamespacedPathcallsPathResolve, which
prependsenv->GetCwd()to a relative path and returns an absolute
\\?\C:\…string.src/env.cc:709→ libuvuv_cwd→GetCurrentDirectoryWreturns the cwd
as a string: whateveruv_chdirpassed toSetCurrentDirectoryW.- libuv
fs__realpath(deps/uv/src/win/fs.c) callsCreateFileWon that
absolute string, thenGetFinalPathNameByHandleW.
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) unlessUV_FS_O_EXLOCKis 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_EXLOCKthrough.
fs.constantsdoes 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:
- Open the allowed root as a directory handle.
- Walk down one component at a time. Open each component relative to the
previous handle withFILE_OPEN_REPARSE_POINT | FILE_DIRECTORY_FILE, and
refuse it if it carriesFILE_ATTRIBUTE_REPARSE_POINT(a junction or
symlink). Swapping a component cannot redirect the walk, because it follows
handles, not strings. write_file(create):NtCreateFile(FILE_CREATE)relative to the
parent handle.- Overwrite and
edit_file:FILE_OPENwithFILE_OPEN_REPARSE_POINT,
then check that the opened file is not a reparse point. create_directory:NtCreateFile(FILE_DIRECTORY_FILE | FILE_CREATE)at
each missing level, relative to the previous handle.move_file(destination):NtSetInformationFilewith
FileRenameInformation, whoseRootDirectoryis the destination
directory's handle.- 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_POINTwithINVALID_PARAMETER, so
retry without it (see ReactOS). OBJ_DONT_REPARSEexists 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:ffiis stable and on by default, andenginescan
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 forwrite_file(create and overwrite),
edit_file,create_directoryand themove_filedestination. Nothing
lands outside, and no outside file is modified. - The regression tests stage the swap deterministically and run on the
windows-latestleg oftypescript.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
- NtCreateFile (winternl.h)
- OBJECT_ATTRIBUTES (ntdef.h)
- Rust CVE-2022-21658 advisory, and
open_link_no_reparseinrust-lang/rustlibrary/std/src/sys/fs/windows/remove_dir_all.rs - ReactOS: FAT rejects FILE_OPEN_REPARSE_POINT
- Wine: RootDirectory handling
- Node.js
node:ffidocs (v26.9.0) - Node v24.20.0 source:
src/node_file.cc(RealPath,Open),src/path.cc(ToNamespacedPath,PathResolve),src/env.cc(Environment::GetCwd),deps/uv/src/win/util.c(uv_cwd,uv_chdir),deps/uv/src/win/fs.c(fs__realpath, share modes)
- 主要言語
- TypeScript
- スター
- 91.7k
- フォーク
- 11.9k
- 平均マージ
- 3時間 30分
- マージ済み PR(30日)
- 204
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
modelcontextprotocol/servers のほかの issue
-
bug v2
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
modelcontextprotocol/servers#5483 ·
メンテナーはふだん 1 日以内に返信
-
bug v2
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
modelcontextprotocol/servers#5482 ·
メンテナーはふだん 1 日以内に返信
-
chore dependencies server-filesystem v2
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
modelcontextprotocol/servers#5481 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
chore v2
難易度 4/5 3〜5日 初心者へのやさしさ 45/100
modelcontextprotocol/servers#5463 ·
メンテナーはふだん 1 日以内に返信
-
git: the string "null" for base_branch, contains or not_contains arrives as None (python-sdk#3055)オープンbug server-git v2 waiting-on-sdk
難易度 3/5 1〜2日 初心者へのやさしさ 68/100
modelcontextprotocol/servers#5176 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
modelcontextprotocol/servers の issue をすべて見る
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
shoelace-style/webawesome#2979 ·
メンテナーはふだん 1 日以内に返信
-
Chip source filter and denial markers miss Storage rows when a Storage activity source is pickedオープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 73/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
omdsh-dev/DSH-better-sidebar#1141 ·
メンテナーはふだん 2 日以内に返信