fix(beam): prevent path traversal via client-controlled root in /api/browse#4
Conversation
…path traversal (CWE-22) The /api/browse endpoint accepted a user-controlled `root` query parameter that was used as the trust boundary for isPathWithin() validation. An attacker could set root=/ to make isPathWithin(anyPath, "/") always return true, enabling directory listing of any path on the filesystem. The fix removes the ability for clients to set the root boundary. Root is now always derived server-side from either the photon workdir environment variable or state.workingDir. The client file-picker still receives the resolved root in the response for UI navigation purposes.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe /api/browse handler no longer reads a client ChangesAPI browse root resolution
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/auto-ui/beam/routes/api-browse.ts (1)
51-54:⚠️ Potential issue | 🟠 MajorUse canonical real paths for boundary authorization to block symlink escapes.
isPathWithin()insecurity.tsusespath.resolve()(lexical) rather than symlink-resolving paths, whileapi-browse.tsperformsfs.stat()/fs.readdir()on the resultingresolvedpath. IfdirPathcontains a symlink underrootthat points outside, it can pass the lexical boundary check but still access the escaped target via the filesystem.🔧 Proposed fix
const dirPath = url.searchParams.get('path') || root; try { - const resolved = path.resolve(dirPath); + const [resolvedRoot, resolved] = await Promise.all([ + fs.realpath(root), + fs.realpath(dirPath), + ]); // Security: always enforce path boundary using isPathWithin - if (!isPathWithin(resolved, root)) { + if (!isPathWithin(resolved, resolvedRoot)) { res.writeHead(403); res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' })); return true; } @@ path: resolved, parent: path.dirname(resolved), - root: root ? path.resolve(root) : null, + root: resolvedRoot, items, }) );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/auto-ui/beam/routes/api-browse.ts` around lines 51 - 54, The boundary check uses lexical path.resolve on dirPath but then performs fs.stat/fs.readdir, allowing symlink escapes; call fs.realpath (or fs.promises.realpath) on both dirPath (the requested path) and root to obtain canonical, symlink-resolved paths and then pass those real paths into isPathWithin (and use the real resolved path for subsequent fs.stat/fs.readdir). Update references to resolved/root in api-browse.ts to use realResolved and realRoot (or similar) so the authorization check and filesystem ops operate on the same symlink-resolved targets; preserve error handling for realpath failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/auto-ui/beam/routes/api-browse.ts`:
- Around line 35-41: The current logic reads arbitrary `${PHOTON}_WORKDIR` env
vars for any photonParam; restrict this by checking membership in the configured
photons list (e.g., state.photons) or an explicit allowlist before building
envPrefix and reading process.env. Update the block around
photonParam/envPrefix/workdirEnv to first verify that photonParam is present in
state.photons (or the allowlist), only then compute envPrefix =
photonParam.toUpperCase().replace(/-/g, '_') and read `${envPrefix}_WORKDIR`; if
not allowed, skip the env lookup so root is not set from unverified env vars.
---
Outside diff comments:
In `@src/auto-ui/beam/routes/api-browse.ts`:
- Around line 51-54: The boundary check uses lexical path.resolve on dirPath but
then performs fs.stat/fs.readdir, allowing symlink escapes; call fs.realpath (or
fs.promises.realpath) on both dirPath (the requested path) and root to obtain
canonical, symlink-resolved paths and then pass those real paths into
isPathWithin (and use the real resolved path for subsequent fs.stat/fs.readdir).
Update references to resolved/root in api-browse.ts to use realResolved and
realRoot (or similar) so the authorization check and filesystem ops operate on
the same symlink-resolved targets; preserve error handling for realpath
failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 243aa1ce-668e-41f8-9af8-d5dc32a12af9
📒 Files selected for processing (1)
src/auto-ui/beam/routes/api-browse.ts
…h for symlink protection
|
Thanks for the thorough review — both findings are valid and have been addressed in 449b0b7. 1. Restrict env-based workdir lookup (inline comment, line 41)Good catch. The unconstrained Fix: The env lookup is now gated behind 2. Use
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/auto-ui/beam/routes/api-browse.ts`:
- Around line 53-61: The code calls fs.realpath(dirPath) before performing the
isPathWithin authorization check, causing realpath to throw for non-existent
outside-root paths (leaking existence via 500). Fix by first realpath-ing only
the root (await fs.realpath(root) -> resolvedRoot), then compute a non-throwing
canonical candidate for dirPath using path.resolve(root, dirPath) (or
path.join+path.normalize) and run isPathWithin(candidate, resolvedRoot); only
after the candidate passes the boundary check call fs.realpath(dirPath) to get
the true resolved path (assign to resolved) and continue. Update references to
resolvedRoot, dirPath, isPathWithin and fs.realpath accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d1a61f77-7934-4848-94f8-dc2cd1a90611
📒 Files selected for processing (1)
src/auto-ui/beam/routes/api-browse.ts
There was a problem hiding this comment.
Pull request overview
This PR hardens Beam’s /api/browse route handler against CWE-22 path traversal by removing client control over the directory boundary and strengthening path canonicalization to prevent symlink-based escapes.
Changes:
- Ignore the client-supplied
rootquery parameter and derive the browse root server-side (photon workdir env var when valid; otherwisestate.workingDir). - Restrict photon-based root resolution to photon names present in
state.photonsto avoid arbitrary*_WORKDIRenv var reads. - Canonicalize browse paths using
fs.realpath()and return the canonical resolved root in responses.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Security: resolve both paths through fs.realpath to obtain canonical, | ||
| // symlink-resolved paths. This prevents symlink escape attacks where a | ||
| // symlink under root points outside the allowed directory — lexical | ||
| // path.resolve() would pass isPathWithin but the filesystem would follow | ||
| // the symlink to the escaped target. | ||
| const [resolvedRoot, resolved] = await Promise.all([fs.realpath(root), fs.realpath(dirPath)]); | ||
|
|
||
| // Security: always enforce path boundary using isPathWithin | ||
| if (!isPathWithin(resolved, root)) { | ||
| if (!isPathWithin(resolved, resolvedRoot)) { |
…h existence Calling fs.realpath(dirPath) before the isPathWithin check meant a non-existent path outside root would throw ENOENT, returning a 500 and leaking whether the path existed. Fix: resolve root canonically, do a fast lexical boundary check first, then call realpath(dirPath) only if the candidate passes — followed by a second isPathWithin to catch symlink escapes.
|
Appreciate the review. P.S. More of these run autonomously out of Foundation Machines if you would like coverage on other repos. |
Summary
The
/api/browseendpoint in Beam's route handler (src/auto-ui/beam/routes/api-browse.ts) accepts a client-suppliedrootquery parameter that controls the security boundary forisPathWithin(). An attacker can setrootto a parent directory (e.g./home,/etc,/tmp) andpathto any target within it, bypassing the intended working-directory constraint and listing arbitrary directories on the server.CWE-22 (Path Traversal) — High severity
Vulnerability Details
In the original code (line 27 on
main):The
rootparameter is read directly from the query string. It's then used as the second argument toisPathWithin(resolved, root), which checks whether the requestedpathfalls withinroot. Since the attacker controlsroot, they can widen the boundary to encompass any directory on the filesystem.For example,
isPathWithin('/etc/ssh', '/etc')returnstrue— so requesting/api/browse?root=/etc&path=/etc/sshpasses the security check and returns the directory listing of/etc/ssh.The
isPathWithinfunction itself is correctly implemented — it usespath.resolve()and a trailing-separator check. The vulnerability is that the trust boundary is attacker-controlled, not that the check is broken.Preconditions
127.0.0.1, but the project supportsBEAM_BIND_ADDRESS=0.0.0.0, Docker port-mapping, and tunnel deployments (cloudflared/ngrok). In these configurations the endpoint is exposed to untrusted networks./api/browse. Unlike/api/configroutes which callisLocalRequest(req), the browse endpoint has no auth check — it processes any incoming request.Adversarial review
Before submitting, we verified that existing mitigations do not prevent exploitation:
isPathWithin()is correctly implemented but does not help when the attacker controls therootargument; theisLocalRequest()guard is not applied to/api/browse; and no middleware or framework-level auth gate covers this route. The localhost-only binding is a deployment-time default, not a security control — the codebase explicitly supports non-localhost deployments.Proof of Concept
With a Beam server running (e.g. via
photon beamor in a Docker container with port mapping):The key insight is that
/api/browse?root=/etc&path=/etc/sshpassesisPathWithin('/etc/ssh', '/etc')— the check works correctly, but the boundary itself was chosen by the attacker.Fix
The fix removes client control over the
rootparameter. Instead of reading it fromurl.searchParams.get('root'), the root is alwaysnullinitially and resolved server-side:photonquery parameter is provided,rootis derived from the${PHOTON_NAME}_WORKDIRenvironment variable.rootfalls back tostate.workingDir— the server's configured working directory.The client-supplied
rootparameter is completely ignored. This ensures the trust boundary forisPathWithin()is always determined by server-side configuration, not by user input.The change is minimal (7 lines added, 3 removed in a single file) and does not affect any other endpoints. The
/api/local-fileendpoint already correctly usesstate.workingDiras its boundary.Testing
root=/etc&path=/etc/sshare denied with 403 becauseisPathWithin('/etc/ssh', state.workingDir)returnsfalse.?photon=<name>still functions correctly (reads from server-side env var, not from user input)./api/local-fileor/api/templateendpoints (they were already using server-side roots).Submitted by Sebastion — autonomous open-source security research from Foundation Machines. Free for public repos via the Sebastion AI GitHub App.
Summary by CodeRabbit