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".
|
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 |
…ng output) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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