Skip to content

fix(core): route renders/file serving through resolveWithinProject chokepoint#1477

Merged
jrusso1020 merged 1 commit into
mainfrom
fix/render-file-path-traversal
Jun 16, 2026
Merged

fix(core): route renders/file serving through resolveWithinProject chokepoint#1477
jrusso1020 merged 1 commit into
mainfrom
fix/render-file-path-traversal

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

What

Route the /projects/:id/renders/file/* handler's attacker-controlled filename
through the shared resolveWithinProject containment chokepoint instead of a
bare 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 composition field on this same render route) goes
through resolveWithinProject.

I verified the behavior empirically before/after:

  • Literal/encoded ../ traversal is NOT reachable over HTTP. Hono's WHATWG
    URL layer normalizes ../ and decodes+collapses %2e%2e before routing, and
    c.req.path is otherwise not percent-decoded — a battery of ../, %2e%2e,
    %252e, ..%2f, ..\, /abs payloads all 404'd without leaking. So the
    plain LFI described in fix(security): add path traversal protection to render file serving #621 is already mitigated upstream.
  • A symlink escape was live. A symlink placed inside rendersDir pointing
    outside 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("..") + startsWith check, which is symlink-unsafe and would
reject legitimate filenames containing a .. substring). Thanks to @hobostay
for surfacing the unguarded route.

How

const fp = resolveWithinProject(rendersDir, filename) (returns null
403), replacing const fp = join(rendersDir, filename). resolveWithinProject
resolves .., then canonicalizes both sides with realpathSync before the
prefix check, so an in-dir symlink can't redirect outside the renders directory.

Test plan

  • Unit tests added/updated — new GET /projects/:id/renders/file/* — path safety
    block: 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 --check clean; tsc --noEmit clean
  • Manual testing performed
  • Documentation updated (if applicable)

🤖 Generated with Claude Code

…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 miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengths:

  • resolveWithinProject (packages/core/src/safePath.ts:59–72) is the right chokepoint: it resolve()s, then walks ancestors calling realpathSync to 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.ts is solid: in-dir file served, escaping symlink rejected (403), in-dir symlink followed safely. Uses the tryCreateSymlink convention from preview.test.ts for Windows CI compat.
  • The two other join(rendersDir, …) calls at lines ~100 and ~248–281 are safe: outputPath = join(rendersDir, jobId + ext) where jobId is server-generated, and the listing loop where f comes from readdirSync output — neither is attacker-controlled user input.

Rule 2 audit — other project-scoped routes in render.ts:

  • body.composition (line ~86) — already guarded with resolveWithinProject(project.dir, …) before the render starts. ✅
  • Delete route (/render/:jobId) — jobId comes from the in-memory renderJobs map; the outputPath is 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

@jrusso1020 jrusso1020 merged commit fada399 into main Jun 16, 2026
47 checks passed
@jrusso1020 jrusso1020 deleted the fix/render-file-path-traversal branch June 16, 2026 02:46
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.

2 participants