Skip to content

fix(security): structural symlink detection via git diff --raw (closes #15)#20

Merged
axisrow merged 1 commit into
mainfrom
sec-patch-symlink-raw
Jul 20, 2026
Merged

fix(security): structural symlink detection via git diff --raw (closes #15)#20
axisrow merged 1 commit into
mainfrom
sec-patch-symlink-raw

Conversation

@axisrow

@axisrow axisrow commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Closes #15 (reopened — #19 missed a bypass).

The bypass in #19

#19 detected symlinks by scanning the patch text for a mode 120000 header line. Codex review found that an existing tracked symlink that Codex retargets emits index <oldsha>..<newsha> 120000 — with NO new file mode / old|new mode line — so the regex missed it. git apply then redirected the symlink to the attacker-chosen host path in repoRoot.

Reproduced: tracked link -> /tmp/benign, retargeted to -> /etc/passwd, produced a patch with only index d0a3aa8..3594a94 120000; git apply --index recreated link -> /etc/passwd.

Fix — structural, format-independent

Replace patch-text scanning with git diff --cached --raw, which reports :<oldmode> <newmode> <oldsha> <newsha> <status>\t<path> for every changed entry. Parse the two mode columns; reject if either is 120000. This catches all symlink variants uniformly — new, retargeted, mode-changed, renamed — because it reads git's structural mode data, not a rendered header string. Patch generation still uses git diff --cached --binary --output (byte-safe); --raw is only the detection pass.

Leave-branch invariant preserved (worktree stays on refusal).

Regression tests (3, all green)

Verified: worktree.test.mjs 16/16; full npm test 159 pass / 4 pre-existing (unchanged).

@axisrow
axisrow force-pushed the sec-patch-symlink-raw branch from 34ba269 to eb3f692 Compare July 20, 2026 03:29
@axisrow

axisrow commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — security (forced-update: TOCTOU-closed redesign)

Initial two-phase detection (--raw check separate from patch gen) was TOCTOU-exploitable. Codex + Claude both found it; Claude demonstrated a ~15% reliable bash busy-poll bypass. Redesigned to frozen-tree.

Reviewer Verdict Finding Status
claude CRITICAL TOCTOU: patch from mutable index, --raw check reads index again — background process mutates index between calls, patch carries symlink while --raw reports clean, git apply replays symlink. ~15% reliable. IN-SCOPE for #15 threat model (worktree = Codex workspace, write access + can spawn). redesigned eb3f692: git write-tree freezes index → patch AND modes both read from immutable tree, no window
codex HIGH diff.external evasion: crafted repo poisons diff driver, patch carries symlink while --raw clean refuted empirically (--raw is structural, not subject to diff.external); but the MEDIUM DoS variant (diff.external via shared commondir poisons repoRoot config) closed by --no-ext-diff --no-textconv on both commands
claude/codex MEDIUM diff.external DoS via linked-worktree shared commondir closed: --no-ext-diff --no-textconv added to patch-gen and diff-tree

Triage: 1 CRITICAL (redesigned) + 1 MEDIUM (closed) — both addressed in eb3f692. The frozen-tree approach closes the TOCTOU class the same way worktree.mjs's leave-branch closed the capture-then-remove class: remove the mutable state between the two reads.

Cycle 2 re-runs on eb3f692 to confirm the frozen-tree redesign is itself sound (write-tree atomicity, diff-tree on tree-object correctness, no remaining window).

 #15)

REDESIGN after four cycles of detection-based fixes each found a new TOCTOU
bypass (index, refs/replace). Same pattern as worktree-cleanup whack-a-mole —
remove the vulnerable path rather than guard it.

Neutralize: apply with git -c core.symlinks=false apply --index. Symlink-mode
entries materialize as regular text files (target path as content), NOT symlinks.
Cannot point at host file, cannot exfil. Class disappears by construction.

Byte-preserving patch and --no-ext-diff kept; detection logic removed.

Tests (3, green): new symlink -> regular file; retargeted -> regular file;
regular file mentioning mode 120000 -> applied normally.

Verified: worktree 16/16; full npm test 159 pass / 4 pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@axisrow
axisrow force-pushed the sec-patch-symlink-raw branch from eb3f692 to 579f6b6 Compare July 20, 2026 04:36
@axisrow

axisrow commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2) — neutralize redesign (forced-update 579f6b6)

Cycle 2 found the frozen-tree approach still had a residual: refs/replace/<tree> race (Codex CRITICAL) — git honors replace-refs for tree objects, mutable via the shared commondir. The 4th detection iteration in a row found a new mutable-indirection layer. Same whack-a-mole pattern as the worktree-cleanup class.

Redesign (same principle as leave-branch): remove the vulnerable path, don't guard it. Instead of detecting symlinks in the patch (which requires reading the patch and a separate check — always a TOCTOU window between two git invocations, across whatever mutable layer git resolves through), NEUTRALIZE: apply with git -c core.symlinks=false apply --index. git then materializes any symlink-mode entry as a regular text file containing the target path, not a symlink. It cannot point at a host file, cannot exfil, cannot append. The symlink-replay class (#15) disappears by construction — no detection, no TOCTOU, no mutable-indirection to chase.

Reviewer Cycle Verdict Finding Resolution
codex 1 needs-attention patch-text scan misses retarget (index-header 120000) redesigned → --raw → frozen-tree → neutralize
claude 1 CRITICAL TOCTOU: patch from index, --raw from index, ~15% bash bypass frozen-tree closed it, but cycle-2 found replace-ref
codex 2 needs-attention frozen-tree: refs/replace/ race between the two reads neutralize eliminates both reads
claude 2 APPROVE frozen-tree sound (missed replace-ref) neutralize is strictly stronger

Neutralize approach (579f6b6): git -c core.symlinks=false apply --index <patch>. No write-tree, no diff-tree, no regex, no detection pass at all. Byte-preserving --output patch transfer and --no-ext-diff/--no-textconv hardening kept.

Regression tests (3, all green): new symlink → regular file (content = target path); retargeted existing symlink → regular file; regular file mentioning "mode 120000" → applied normally.

Cost: legitimate symlinks in the worktree also land as text files (rare in code; render layer already warns to inspect special files manually). Acceptable for a rescue-transfer path.

Cycle 3 re-runs on 579f6b6 to confirm neutralize is airtight.

@axisrow

axisrow commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 3) — neutralize redesign — APPROVE

Claude: APPROVE, definitive. Codex: not run (classifier blocked node; will retry post-restart).

Question Answer
Does core.symlinks=false prevent ALL symlink apply cases? YES — 6 vectors tested (new/retarget/mode-change/rename/binary/submodule), all neutralized
Can attacker re-enable via config (shared commondir)? NO — CLI -c has highest precedence; 5 override paths tested (local/global config, GIT_CONFIG_PARAMETERS, include.path, GIT_CONFIG_COUNT), all defeated
Documented escape hatch? NONE — git man page: "If false, symbolic links are checked out as small plain files that contain the link text." Absolute rule.
Side-channel via hooks/filters? NONE — git apply does not fire hooks; .gitattributes filters don't run on apply
Path traversal via crafted patch? BLOCKED — git apply rejects b/../ and absolute paths

The symlink-replay class (#15) is dead by construction. Cycles 1-2 failed because DETECTION requires a second observation (race-able); cycle 3 tells git "do not create symlinks, period" — no race because no second observation.

16/16 tests green. lstatSync (not statSync) correctly distinguishes regular file from symlink-to-file. Merge-ready.

@axisrow
axisrow merged commit 3bbf13c into main Jul 20, 2026
@axisrow
axisrow deleted the sec-patch-symlink-raw branch July 20, 2026 04:44
axisrow added a commit that referenced this pull request Jul 20, 2026
Security hardening release: leave-branch worktree cleanup (#12) + 4 security
fixes (#18 symlink guard, #20 core.symlinks=false neutralize, #22 eliminate
info/exclude write, #23 accumulation warn). rescue default --background (#8).
test-teardown (#5). All verified with cycle-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(high): symlink-mode blob in worktree patch replays a host-file symlink into repoRoot

1 participant