fix(core): route renders/file serving through resolveWithinProject chokepoint#1477
Conversation
…okepoint The `/projects/:id/renders/file/*` route joined attacker-controlled wildcard input straight onto rendersDir with a bare join() + readFileSync and no containment check — the only project-scoped filesystem route that skipped the resolveWithinProject chokepoint every sibling route uses. Literal/encoded `../` traversal is collapsed upstream by Hono's WHATWG URL normalization (verified empirically), so the plain LFI is not reachable over HTTP. But a symlink living inside rendersDir and pointing outside it was still followed and served verbatim (verified: leaked an external secret, 200 OK). Routing through resolveWithinProject canonicalizes with realpath before serving, closing the symlink escape and making the route's safety independent of the URL layer's normalization behavior. Adds regression coverage: serves an in-dir file, rejects an escaping symlink (403), and still serves a symlink that stays inside rendersDir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
Strengths:
resolveWithinProject(packages/core/src/safePath.ts:59–72) is the right chokepoint: itresolve()s, then walks ancestors callingrealpathSyncto dereference symlinks before the prefix check. Symlink-in-dir-pointing-outside is exactly the failure class here; the fix is correctly targeted.- The three-case test suite in
render.test.tsis solid: in-dir file served, escaping symlink rejected (403), in-dir symlink followed safely. Uses thetryCreateSymlinkconvention frompreview.test.tsfor Windows CI compat. - The two other
join(rendersDir, …)calls at lines ~100 and ~248–281 are safe:outputPath = join(rendersDir, jobId + ext)wherejobIdis server-generated, and the listing loop wherefcomes fromreaddirSyncoutput — neither is attacker-controlled user input.
Rule 2 audit — other project-scoped routes in render.ts:
body.composition(line ~86) — already guarded withresolveWithinProject(project.dir, …)before the render starts. ✅- Delete route (
/render/:jobId) —jobIdcomes from the in-memoryrenderJobsmap; theoutputPathis server-generated, not user-supplied. ✅ - No other route in this file takes user-supplied filename input without a containment guard.
CI:
All blocking checks pass (Test, Build, Typecheck, Lint, Format, Fallow, SDK, Preflight across all packages). Regression shards are still queued (pending at review time — normal 18–25 min queue lag per infra conventions). This is a pure server-side path guard with no renderer or composition logic touched; shard results are not expected to be impacted.
No blockers, no important findings. Clean fix closing a genuine symlink-escape path.
Verdict: APPROVE
Reasoning: Correct targeted fix — routes the only unguarded attacker-controlled filename through the existing resolveWithinProject chokepoint. All tests pass, Rule 2 audit finds no sibling gaps.
— Magi
What
Route the
/projects/:id/renders/file/*handler's attacker-controlled filenamethrough the shared
resolveWithinProjectcontainment chokepoint instead of abare
join()+readFileSync.Why
This was the only project-scoped filesystem route that joined wildcard path
input straight onto a base dir with no containment check — every sibling route
(files, file-mutations, the
compositionfield on this same render route) goesthrough
resolveWithinProject.I verified the behavior empirically before/after:
../traversal is NOT reachable over HTTP. Hono's WHATWGURL layer normalizes
../and decodes+collapses%2e%2ebefore routing, andc.req.pathis otherwise not percent-decoded — a battery of../,%2e%2e,%252e,..%2f,..\,/abspayloads all 404'd without leaking. So theplain LFI described in fix(security): add path traversal protection to render file serving #621 is already mitigated upstream.
rendersDirpointingoutside it was followed and served verbatim (an external secret leaked,
200 OK).After the fix it returns
403.So this is hardening + symlink-escape closure + making the route's safety
independent of the URL layer's normalization (defense-in-depth), rather than the
live
../LFI the original report assumed.Supersedes #621 (same route; uses the existing chokepoint helper rather than an
ad-hoc
includes("..")+startsWithcheck, which is symlink-unsafe and wouldreject legitimate filenames containing a
..substring). Thanks to @hobostayfor surfacing the unguarded route.
How
const fp = resolveWithinProject(rendersDir, filename)(returnsnull→403), replacingconst fp = join(rendersDir, filename).resolveWithinProjectresolves
.., then canonicalizes both sides withrealpathSyncbefore theprefix check, so an in-dir symlink can't redirect outside the renders directory.
Test plan
GET /projects/:id/renders/file/* — path safetyblock: serves an in-dir file, rejects an escaping symlink (403), and still
serves a symlink that stays inside
rendersDir. (render.test.ts, 19/19 pass)bunx oxlint/bunx oxfmt --checkclean;tsc --noEmitclean🤖 Generated with Claude Code