fix: win32 の O_EXCL が dangling symlink を通す穴を create 系 4 経路で塞ぐ - #506
Merged
Conversation
#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
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
変更内容
fs:write-new/fs:create-file/fs:create-directoryとwriteFileAtomicNoFollowの tmp 作成に guard を前置。lstatが増えるのは flag が落ちる platform(win32)だけで、POSIX ではemulated === falseにより no-opmkdir→ guard」順序をprepareCreateTargetに集約(呼び出し側への複製を排除)Already existsではなく ELOOP になるが、platform で判定材料を割らない方を採るfs:create-directoryの非 recursivemkdirは当時 win32 の挙動が未実測だったので、実測を待たず guard を前置して未検証の前提に production を乗せない判断にした(下記のとおり実測は EEXIST で、guard は結果に依存しない)O_EXCLの割れが tmp 作成だけでなく create 系 3 経路にも効く点)mkdirの live / dangling、dangling junction に対する guard、親が dangling のときの recursive mkdir動作確認
win32-fs-probeを含む。2 回目の run)e2e/electron-e2ejob で green(ローカルは sandbox 制約で実行不可)win32 実測の結果(推測期待値は 2 本とも割れた → 実測値へ pin し直した)
初回 CI で
win32-fs-probeが fail し、推測で置いた 2 本の期待値がどちらも安全側に割れた:mkdirに dangling symlinkO_EXCLと同系に follow して作る後者により、レビューで 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 runtsc --noEmit -p tsconfig.{node,web,e2e}.json./node_modules/.bin/biome check --write electron/ src/bash scratchpad/mut167.sh(guard 削除 4 + 位置移動 1)gh pr checks 506 --watchwin32-fs-probe含む。1 回目は probe のみ fail → 期待値を実測値へ pin し直して 2 回目 green)/simplify4 観点(sonnet)レビュー指摘と対応
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 件
Draft 判定