Skip to content

fix(security): resolve symlinks in path validation to block out-of-root reads (#527)#724

Merged
colbymchenry merged 2 commits into
mainfrom
security/path-validation-realpath
Jun 8, 2026
Merged

fix(security): resolve symlinks in path validation to block out-of-root reads (#527)#724
colbymchenry merged 2 commits into
mainfrom
security/path-validation-realpath

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

validatePathWithinRoot was purely lexical (path.resolve + startsWith), so an in-repo symlink whose logical path is inside the project root but whose real target escapes it passed validation. Both content-serving read sinks (codegraph_node includeCode, codegraph_explore source) then readFileSync'd the returned path and followed the symlink, leaking out-of-root file contents (e.g. ~/.ssh, /etc) to the agent.

Fix

A realpath layer on validatePathWithinRoot: after the cheap lexical check, resolve symlinks on both the candidate path and the root and re-compare — rejecting anything whose real path escapes the root, while still allowing symlinks that stay within the root (no over-blocking). Comparison is case-insensitive on Windows (NTFS + realpath casing). Not-yet-existing paths (ENOENT) fall back to the lexical result so about-to-be-written files still validate; other resolution errors reject. Removes the dead, never-called isPathWithinRoot / isPathWithinRootReal helpers (the latter a footgun — it returned true on realpath failure).

Tests (RED → GREEN)

New tests in security.test.ts, written first and failing on unfixed main — the end-to-end case proved the leak (getCode returned the full out-of-root file body). Coverage: in→out file & dir symlinks rejected, in→in allowed, ../ rejected, ENOENT allowed, plus the end-to-end getCode guarantee.

Cross-platform validation

Platform Security suite Full suite
macOS (native) 42 pass / 2 skip 1259 pass, 0 fail
Linux (Docker node:22-bookworm, --init) 42 pass (escape blocked) 1259 pass, 0 fail
Windows (Parallels VM, Node 24, symlink-capable) 42 pass (escapes ran for real) deltas all pre-existing (matched origin/main baseline) or a flaky daemon race

Addresses #527.

🤖 Generated with Claude Code

colbymchenry and others added 2 commits June 8, 2026 00:50
…ot reads (#527)

validatePathWithinRoot was purely lexical (path.resolve + startsWith), so an
in-repo symlink whose logical path is inside the project root but whose real
target escapes it passed validation — and both content-serving read sinks
(codegraph_node includeCode, codegraph_explore source) then readFileSync'd it,
leaking out-of-root file contents (e.g. ~/.ssh, /etc) to the agent.

Add a realpath layer: after the lexical check, resolve symlinks on both the
candidate path and the root and re-compare, rejecting anything whose real path
escapes the root. An in-root symlink is still allowed (no over-blocking).
Comparison is case-insensitive on Windows (NTFS + realpath casing). Not-yet-
existing paths (ENOENT) fall back to the lexical result so about-to-be-written
files still validate; other resolution errors reject.

Removes the dead, never-called isPathWithinRoot / isPathWithinRootReal helpers
(the latter a footgun — it returned true on realpath failure). Adds RED->GREEN
tests: in->out file/dir symlinks rejected, in->in allowed, ../ rejected, ENOENT
allowed, plus an end-to-end test proving getCode no longer serves an out-of-root
file reached through a dir symlink.

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.

1 participant