fix(dashboard): prevent symlink-following file disclosure when indexing untrusted repos#476
Conversation
…ng untrusted repos The dashboard /file-content.json reader (packages/dashboard/vite.config.ts readSourceFile) validated the requested path only textually (rejects ../absolute, checks the graph-derived allowlist) and then called fs.statSync / fs.readFileSync, both of which follow symlinks. A symlink committed in an indexed repo (e.g. src/config.txt -> /home/user/.ssh/id_rsa) is textually in-root, is enumerated by `git ls-files`, passes statSync().isFile() during indexing, and so becomes an allowlisted graph node. Opening that file node in the dashboard then returned the symlink target's contents (SSH keys, a sibling project's .env, etc.) — out-of-root disclosure when running /understand on an untrusted repo. - vite.config.ts: realpathSync-resolve absoluteFile and re-confirm it stays inside the realpath of the project root before stat/read (load-bearing fix — closes the read even if a symlink node is already in the allowlist). - scan-project.mjs: lstat instead of stat so symlinks are not followed at index time and never enter the graph/allowlist, matching the recursive-walk fallback which already skips symlinks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ae65fadf0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| !realRelative || | ||
| realRelative === ".." || | ||
| realRelative.startsWith(`..${path.sep}`) || | ||
| path.isAbsolute(realRelative) |
There was a problem hiding this comment.
Reject symlink nodes in the dashboard reader
When users open a graph generated before the scanner change (or otherwise containing an allowlisted symlink node), a link such as src/config.txt -> .env still passes this guard because the resolved target is inside realRoot; the later statSync/readFileSync(absoluteFile) then follows the symlink and serves an ignored file that was never itself graph-allowlisted. Since this function is the load-bearing protection for already-allowlisted symlinks, it should reject symlink paths (or require the resolved target path to be allowlisted) rather than only checking that the real path stays under the project root.
Useful? React with 👍 / 👎.
|
Hi @Lum1104 — this is the PR for the symlink-following file-disclosure we discussed over email; thanks for inviting it. It's intentionally minimal (2 files):
No new deps and no version bump (left for your release flow). Happy to adjust naming, add a regression test, or split it however you prefer. |
|
Reviewed for the stated focus: does this actually prevent symlink-based file disclosure via What's correct
Minor
Note: no tests were added. A small regression test (symlink → out-of-root target returns the containment rejection; symlink node never enters the allowlist after scan) would lock this in, since the whole point is preventing a silent regression to symlink-following. Overall: accurate root-cause analysis, the fix actually closes the disclosure, and it correctly handles the parent-symlink case that's easy to miss. I'd consider switching the read to |
Summary
Per the security email thread with @Lum1104 (who kindly invited a PR): the dashboard's
/file-content.jsonreader follows symlinks, so running/understandon an untrusted repo that contains a committed symlink (e.g.src/config.txt -> /home/user/.ssh/id_rsa) can disclose out-of-root files — SSH keys, a sibling project's.env, etc. — in the code viewer.Root cause
readSourceFile()(packages/dashboard/vite.config.ts) validates the requested path only textually (rejects..//absolute, checks the graph-derived allowlist) and then callsfs.statSync/fs.readFileSync, both of which follow symlinks. A symlink committed in the indexed repo is textually in-root, is listed bygit ls-files, passesstatSync().isFile()during indexing (scan-project.mjs), and so becomes an allowlisted graph node — clicking it returns the symlink target's contents.Fix (2 files)
vite.config.ts—realpathSync-resolve the file and re-confirm it stays inside the realpath of the project root before stat/read. Load-bearing fix: closes the read even if a symlink node is already in the allowlist.scan-project.mjs— uselstatinstead ofstatso symlinks are not followed at index time and never enter the graph/allowlist (matching the recursive-walk fallback /extract-domain-context.py, which already skip symlinks).Minimal, no new deps, no version bump (left for your release flow). Happy to adjust.
🤖 Generated with Claude Code