Skip to content

fix: serve projects and assets whose names contain @ % # & or ? - #4415

Merged
miguel-heygen merged 6 commits into
mainfrom
fix/studio-server-encoded-project-ids
Sep 24, 2026
Merged

miguel-heygen merged 6 commits into
mainfrom
fix/studio-server-encoded-project-ids

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What changes for a user

A project whose name contains @, %, #, & or ? now loads its files, preview assets, sub-compositions, thumbnails and waveforms when the client percent-encodes the name in the URL. Before, those requests answered 404 (403 for the files route), so a project named from a sentence with an @ mention showed no preview or timeline pictures. hyperframes play and hyperframes present had the same problem with asset and scene files named with those characters.

Why

Seven routes turned a request path into a file path by cutting a prefix out of Hono's c.req.path. Hono decodes that path with decodeURI, which leaves %40 %25 %23 %26 %3F encoded. So the cut missed (studio-server, where the prefix holds the decoded project id), or the file name kept its escapes (CLI, which never decoded).

One helper, requestSubPath(url, route) in @hyperframes/studio-server, now takes the part after the route by segment from the raw URL and decodes it once. The seven sites:

Package Site Route
studio-server routes/files.ts resolveProjectPath (six callers: files, file-mutations, gsap-animations, gsap-mutations, gsap-mutations-batch, gsap-mutation-rollback) projects/:id/<route>
studio-server routes/preview.ts projects/:id/preview/comp
studio-server routes/preview.ts projects/:id/preview
studio-server routes/thumbnail.ts projects/:id/thumbnail
studio-server routes/waveform.ts projects/:id/waveform
cli commands/play.ts registerCompositionRoute composition
cli commands/present.ts (route extracted into registerPresentCompositionRoute, same behaviour) composition

Two more defects in the same routes, fixed here:

  • thumbnail/* built its internal preview URL from the raw id and path, so # turned the rest into a fragment, ? into a query string, and a lone % was malformed. It now encodes both.
  • thumbnail/* and waveform/* joined the decoded path with no containment check, so an encoded ../ reached outside the project. That was the same on main. They now use the guards their siblings already use: resolveWithinProject like preview/comp/* for compositions, isWithinProjectRoot like preview/* for read-only assets.
  • An empty thumbnail/ path resolved to the project folder and crashed reading it (500). It now answers 404.

Name matrix

studio-server: src/routes/projectIdEncoding.test.ts runs every name as a project id against all five routes, plus two traversal cases. cli: src/commands/compositionRoute.encoding.test.ts runs the same names as asset file names against play and present (? is skipped on Windows, where file names cannot hold it).

Name files preview asset preview comp thumbnail waveform
A @HyperFrames launch main fails, fixed main fails, fixed main fails, fixed main fails, fixed main fails, fixed
50% off main fails, fixed main fails, fixed main fails, fixed main fails, fixed main fails, fixed
#2 take main fails, fixed main fails, fixed main fails, fixed main fails, fixed main fails, fixed
Tom & Jerry main fails, fixed main fails, fixed main fails, fixed main fails, fixed main fails, fixed
why? main fails, fixed main fails, fixed main fails, fixed main fails, fixed main fails, fixed
double space, accents, emoji pass on both pass on both pass on both pass on both pass on both

play and present: with the old line, both fail the same five reserved names (10 of 16); on this branch, 16 passed.

What I measured

  • Against main's four route files, the new tests fail 27: the five reserved characters on all five routes, plus both traversal tests. On this branch: exit 0, 47 passed (the extra test is the empty thumbnail path, which fails with a 500 without its check).
  • Mutations, each run: removing only the waveform guard fails only the waveform traversal test. Removing only the thumbnail URL encoding fails only the thumbnail cases for #, % and ?. Skipping one segment too few in the helper turns the matrix red.
  • CLI on this branch: compositionRoute.encoding.test.ts 16 passed and play.test.ts 14 passed, each exit 0. Putting only present back on the old line fails only present's five reserved names.
  • Route suites on this branch: files.test.ts 80 passed, files.pathSafety.test.ts 25 passed, preview.test.ts 52 passed, thumbnail.test.ts 20 passed, each exit 0. tsc --noEmit exit 0 in studio-server and cli. oxfmt --check exit 0 on every changed file.
  • Live, the CLI's Studio server serving a project named A @HyperFrames launch, requested as A%20%40HyperFrames%20launch (first commit):
Request main this branch
files/index.html 403 200
preview/clip.mp4 404 200
preview/index.html 404 200
preview/comp/index.html 404 200

No visual change: server routing only.

What I did NOT exercise

  • A real thumbnail capture in a browser. The test checks the URL handed to the generator parses back to the exact project and file, and that the route serves the result.
  • Hosts other than the CLI server and app.request in tests. The helper finds the first projects segment, so a host that mounts the API under a prefix still works unless the prefix itself contains a projects segment.
  • render.ts renders/file/ and the engine's file server take a path the same way, but the names there are generated (job ids), so this bug does not reach a user. Not changed.

Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.
…for any name

The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.
Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.
…s 404

An empty thumbnail path resolved to the project directory and crashed reading it (500).

@jrusso1020 jrusso1020 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.

Approve at 076ef518. I read the helper, all seven call sites and both new test files, and ran them locally.

Encoding is applied once. requestSubPath splits new URL(url).pathname into raw segments and runs decodeURIComponent once on everything after the route. The old c.req.path + decodeURIComponent pair is gone from every site, so nothing decodes twice. I checked this directly: %252e%252e%252Fsecret.txt comes back as the literal %2e%2e%2Fsecret.txt, and the files route serves the file with that literal name.

No new traversal. Decoding can now turn %2F into a separator, so containment has to happen after the decode. Every consumer does it that way:

  • resolveProjectPathresolveWithinProject
  • preview/compresolveWithinProject
  • previewisWithinProjectRoot
  • thumbnail → new resolveWithinProject
  • waveform → new isWithinProjectRoot
  • CLI play / presentisSafePath

I ran a throwaway probe: 75 cases, covering the five routes × ..%2F, %2e%2e%2F, %2E%2E%2F, sub%2F..%2F..%2F and a nested proj%2F.. escape, × .txt/.html/.mp3, all aimed at a sibling of the project dir. None returned 200 or leaked the file. In-project controls on files, preview and preview/comp returned 200, so the probe wasn't passing because everything 404s. I also re-ran the PR's own mutations: disabling the waveform guard or the thumbnail guard each fails exactly one traversal test in projectIdEncoding.test.ts.

Tests at head: studio-server projectIdEncoding, files, files.pathSafety, preview and thumbnail: 224 passed. CLI compositionRoute.encoding and play: 30 passed.

I checked the PR body's claim of seven sites against the source. The only remaining c.req.path users are render.ts renders/file/ (generated job ids, as the body says) and the CLI's static SPA handler. Neither takes a user-named path.

Nit, not blocking: a malformed escape (/files/50%zz) makes decodeURIComponent throw, and the route answers 500. Studio-server already behaved this way on main. For play / present it is new, since the old line never decoded and returned 404. Catching the URIError in the helper and treating it as not-found would make every site answer 404.

— Rames

Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.

@jrusso1020 jrusso1020 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.

Re-approve at dd4cec13. The delta since my approval 5304588945 at 076ef518 is one commit, dd4cec13b, touching thumbnail.ts only.

readCompositionSource opens the file once, then does fstatSync and readFileSync(fd) on that same descriptor. That closes the stat-then-read window CodeQL flagged. Behaviour is otherwise the same as the head I approved:

  • ENOENT returns "missing", and the thumbnail is still generated from the preview. On the old head the existsSync guard did the same.
  • A directory, or anything that isn't a regular file, returns "not-a-file" and answers 404. The empty thumbnail/ case is still covered.
  • resolveWithinProject still runs before the open, so the containment I probed last round is unchanged.
  • The fd is closed in finally on every return.

Tests at head: projectIdEncoding and thumbnail passed 67 (47 + 20).

Nit, not blocking: on Windows, openSync on a directory throws (EISDIR/EPERM) instead of returning a descriptor. It isn't ENOENT, so it is rethrown and the empty-path request answers 500 there rather than 404. Mapping EISDIR to "not-a-file" would make it platform-independent.

— Rames

@miguel-heygen
miguel-heygen merged commit fed45ba into main Sep 24, 2026
57 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-server-encoded-project-ids branch September 24, 2026 14:39
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