Skip to content

fix: win32 の O_EXCL が dangling symlink を通す穴を create 系 4 経路で塞ぐ - #506

Merged
ymnao merged 5 commits into
mainfrom
fix/win32-o-excl-dangling-symlink
Aug 16, 2026
Merged

fix: win32 の O_EXCL が dangling symlink を通す穴を create 系 4 経路で塞ぐ#506
ymnao merged 5 commits into
mainfrom
fix/win32-o-excl-dangling-symlink

Conversation

@ymnao

@ymnao ymnao commented Aug 16, 2026

Copy link
Copy Markdown
Owner

概要

win32 の O_EXCL(Windows の CREATE_NEW)が dangling symlink を follow して解決先に file を作ることが #500 の windows-latest 実測で判明した。「O_EXCL は既存 entry が symlink であっても EEXIST で落ちる」という POSIX 規定を security の根拠にしていた 4 経路が、win32 では workspace 外への新規作成を許す。

4 経路すべてに rejectEndSymlinkWhenEmulated#451 の既存 guard)を前置して、拒否水準を platform で割らないようにした。

実害の主役は #504 が挙げた atomic write の tmp ではなく、path が renderer 指定でそのまま予測可能な fs:write-new / fs:create-file#499)の方。tmp は 48bit 乱数への先回りが要るので極小。

関連 Issue

closes #504
closes #499

移行 Stage

  • Stage 6: 仕上げ・配布・切り替え

変更内容

  • fs:write-new / fs:create-file / fs:create-directorywriteFileAtomicNoFollow の tmp 作成に guard を前置。lstat が増えるのは flag が落ちる platform(win32)だけで、POSIX では emulated === false により no-op
  • 3 経路の「親 mkdir → guard」順序を prepareCreateTarget に集約(呼び出し側への複製を排除)
  • 拒否の errno は 434 follow-up: Windows では検索の symlink 境界がゲート未評価経路で効かない #451 の規約に揃えて ELOOP。win32 でだけ既存 symlink 上の create が Already exists ではなく ELOOP になるが、platform で判定材料を割らない方を採る
  • fs:create-directory の非 recursive mkdir は当時 win32 の挙動が未実測だったので、実測を待たず guard を前置して未検証の前提に production を乗せない判断にした(下記のとおり実測は EEXIST で、guard は結果に依存しない)
  • ELOOP の user 向け文言「…開けませんでした」は create 経路が ELOOP を返すようになったことで全ケースで真ではなくなったため「…操作できませんでした」へ
  • ADR-0011 の win32 実測サマリを追随(O_EXCL の割れが tmp 作成だけでなく create 系 3 経路にも効く点)
  • probe に 4 本追加: 非 recursive mkdir の live / dangling、dangling junction に対する guard、親が dangling のときの recursive mkdir

動作確認

  • unit: 3165 passed / 16 skipped(140 files)
  • typecheck: node / web / e2e の 3 tsconfig すべて
  • lint: biome
  • mutation: guard の削除 4 種 + 位置移動 1 種の計 5 mutant がすべて KILL
  • CI: 全 check green(win32-fs-probe を含む。2 回目の run)
  • e2e: CI の e2e / electron-e2e job で green(ローカルは sandbox 制約で実行不可)

win32 実測の結果(推測期待値は 2 本とも割れた → 実測値へ pin し直した)

初回 CI で win32-fs-probe が fail し、推測で置いた 2 本の期待値がどちらも安全側に割れた:

ケース 推測 実測(windows-latest)
非 recursive mkdir に dangling symlink O_EXCL と同系に follow して作る EEXIST(follow しない。POSIX と同じ)
親が dangling のときの recursive mkdir follow して親が live 化する ENOTDIR(darwin と同一。解決先に何も作られない)

後者により、レビューで surface した「親 component 経由の escape」(#505)は win32 でも成立しないことが確定したので #505 は close した。fs:create-directory の guard は「拒否を platform 依存な挙動に委ねない」という事前の判断で置いたもので、実測結果に関わらずそのまま残している。

検証エビデンス

リスク分類

tier: medium — reasons: (classify-risk.sh の出力は "reasons": []。tier のみ medium 判定)

実行した検証

種別 コマンド 結果
テスト env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 -u GIT_CONFIG_KEY_1 -u GIT_CONFIG_VALUE_1 ./node_modules/.bin/vitest run PASS 3165 passed / 16 skipped(139 files passed / 1 skipped)
Typecheck tsc --noEmit -p tsconfig.{node,web,e2e}.json PASS(3 本とも)
Lint ./node_modules/.bin/biome check --write electron/ src/ PASS(331 files, no fixes applied)
Mutation bash scratchpad/mut167.sh(guard 削除 4 + 位置移動 1) 5 mutant すべて KILL
CI gh pr checks 506 --watch 全 check green(win32-fs-probe 含む。1 回目は probe のみ fail → 期待値を実測値へ pin し直して 2 回目 green)
レビュー /simplify 4 観点(sonnet) 4 findings(2 fixed / 2 skip)
レビュー code-reviewer(fable)1 周目 7 findings(Critical 0 / Warning 4 / Suggestion 3)→ 6 fixed
レビュー code-reviewer(fable)2 周目 6 findings(Warning 1 / Suggestion 5)→ 4 fixed(自分が書いた記述の誤りのみ)
レビュー codex-review security 1 finding(HIGH/95)→ CONFIRMED + REPORT-ONLY((b) #505 起票 → 実測で前提が否定され close)

レビュー指摘と対応

codex-review security

HIGH/95 electron/main/ipc/fs.ts:250 — Windows で認可後の dangling junction を含む親パスに対して recursive mkdir がリンク先を workspace 外に作成して junction を有効化し、後段の作成処理が外部パスへ書き込める → CONFIRMED + REPORT-ONLY((b) #505 起票)

fix しなかった理由: 閉じるには中間 component の traversal guard(各 path 要素の no-follow 検査、または mkdir 後の親 realpath 再検証)が要る。これは open-nofollow.ts の doc が「fd 相対 traversal が要るが Node は expose していない」として明示的に受容している範囲の再定義にあたり、本 PR のレビュー範囲を押し上げるため。

その後の実測で前提が否定された: win32 の recursive mkdir は dangling な中間 component を follow せず ENOTDIR で失敗する(本 PR の probe で実測)。#505 はこの実測を添えて close した。

code-reviewer(fable)で fix したもの

1 周目: guard の順序不変条件が 3 箇所に複製 → prepareCreateTarget に集約 / 「win32 の mkdir 挙動は未実測」という時限コメント → 全時点で真な根拠へ / guard を mkdir 後に置く根拠が過大 → 「窓を縮めるため」へ / ELOOP 文言 / ADR-0011 の stale / dangling junction の probe 追加。

2 周目(いずれもこの PR 自身が書いた文の誤りなので周回上限に関わらず即 fix): prepareCreateTarget の「open までの窓」が create-directory では mkdir / ADR の「O_EXCL の platform 依存」が mkdir 由来の排他性を含んでいない / writeFileAtomicNoFollow の doc の因果が逆に読める / 親 dangling probe のコメントが影響範囲を create 系に閉じて書いていた。

/simplify で skip したもの: importWithoutNoFollow の helper 抽出(2 箇所目。repo 慣行は 3-4 箇所目)/ writeFileAtomicNoFollow の 2 つの lstat の並列化(単発 export 経路)。

追跡先

backlog delta: 起票 1 件(#505、実測で前提が否定され同 PR 内で close)/ 本 PR で close 2 件(#504 / #499)/ 現在 open 16 件

Finding (file:line — summary) 行き先 URL / 記録
electron/main/ipc/fs.ts:250 — 親 dangling で外部へ着地しうる (b) 統合 issue(fable Warning と codex HIGH が同根) #505 (実測で前提が否定され close)
.github/workflows/ci.yml — win32-fs-probe が非 required (c) 対応しない 追跡しない (user 指示: 分類表を ok で承認。merge 前に job 結果を確認する運用で対応 — 実際に初回 fail を検出して期待値を pin し直した)

Draft 判定

ymnao and others added 5 commits August 16, 2026 23:02
#500 の probe で、win32 の O_EXCL (CREATE_NEW) は dangling symlink を
follow して解決先に file を作ることが実測で判明した (#504)。「O_EXCL は
symlink であっても EEXIST」という POSIX 規定を根拠にしていた経路は、
win32 では workspace 外への新規作成を許してしまう。

- fs:write-new / fs:create-file / fs:create-directory と
  writeFileAtomicNoFollow の tmp 作成に rejectEndSymlinkWhenEmulated を
  前置する。#451 の既存機構をそのまま再利用し、lstat が増えるのは
  flag が落ちる platform だけ
- guard は親の recursive mkdir の後に置く。前だと親未作成時に lstat が
  ENOENT で素通りし、末端を見ないまま open へ抜ける
- 拒否の errno は #451 の規約に揃えて ELOOP。win32 でだけ既存 symlink 上の
  create が Already exists ではなく ELOOP になるが、platform で判定材料を
  割らない方を採る
- fs:create-directory の非 recursive mkdir は win32 の挙動が未実測なので、
  実測を待たず guard を前置して未検証の前提に production を乗せない

実害の主役は #504 が挙げた tmp (48bit 乱数への先回りが要る) ではなく、
path が renderer 指定でそのまま予測可能な fs:write-new / fs:create-file
の方 (#499)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/simplify の 2 観点 (simplification / altitude) が同じ機構に収束したため、
呼び出し側に 3 回複製されていた順序不変条件を prepareCreateTarget に
まとめた。将来 4 つ目の create 経路が増えたときにコメントを読んで順序を
再現する必要が無くなる。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
code-reviewer (fable) の Warning 4 / Suggestion 3 のうち、この diff 自身が
持ち込んだものを解消した。

- fs.ts の「win32 の mkdir 挙動は未実測」コメントは、同じ PR が積む probe が
  走った時点で偽になる時限記述だった。「拒否を platform 依存の挙動に委ねない」
  という全時点で真な根拠に書き直す
- guard を mkdir の後に置く根拠を「逆順だと検査が空振りする」と書いていたが
  過大。末端に entry があるなら親も実在するので定常状態の検出力は同じで、
  実差は lstat→open の窓の長さだけ
- ELOOP の文言「開けませんでした」は create 経路が ELOOP を返すように
  なったことで全ケースで真ではなくなった。操作非依存の文言へ
- ADR-0011 の win32 実測サマリが「O_EXCL の割れは tmp 作成に効く」で
  止まっていたので、create 系 3 経路と guard 前置を追記
- probe に dangling junction (create-directory の末端に置かれる現実的な形) と
  親が dangling のときの recursive mkdir を追加。後者は darwin で ENOTDIR /
  外部に何も作られないことを node probe で実測した上で win32 側を測る

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
いずれもこの PR 自身が書いた文の誤り。

- prepareCreateTarget の「lstat から open までの窓」は create-directory の
  後段が mkdir なので全ケースで真でない
- ADR-0011 の「拒否を O_EXCL の platform 依存な挙動に委ねない」は
  create-directory の排他性が mkdir の EEXIST 由来である点で不正確
- writeFileAtomicNoFollow の doc が「POSIX では O_EXCL が拒否するので
  guard は no-op」と因果を逆に読める形だった。no-op の機構は
  emulated === false の方
- 親 dangling の probe コメントが影響を create 系 3 経路に閉じて書いていた。
  同じ recursive mkdir は fs:write / fs:rename / git:resolve-conflict も通る

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #506 の初回 CI (win32-fs-probe) で、推測で置いていた 2 本の期待値が
どちらも安全側に割れた。

- 非 recursive mkdir に dangling symlink → EEXIST。O_EXCL と違い follow
  しない (POSIX と同じ)
- 親が dangling の recursive mkdir → ENOTDIR。darwin と同一で、解決先には
  何も作られない

後者により #505 が前提にしていた「win32 では follow して親が live 化する」
escape は成立しないことが確定した。production 側の guard は「拒否を
platform 依存の挙動に委ねない」判断で置いたものなので、実測結果に関わらず
そのまま残す。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ymnao
ymnao merged commit 3c40157 into main Aug 16, 2026
18 checks passed
@ymnao
ymnao deleted the fix/win32-o-excl-dangling-symlink branch August 16, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant