Conversation
Static asset handlers joined the raw request path onto their root directory without checking where it landed. Hono decodes percent escapes while routing, so an encoded backslash (%5c) arrives as a real separator on Windows and dot segments arrive already collapsed — "/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files above the studio bundle root (embedded Studio server) and above compiledDir/projectDir (engine render file server). Browsers never request those shapes, but anything that can reach the bound port can: raw local clients, LAN peers when the preview binds 0.0.0.0, and a shared-machine process hitting the render server. Both handlers now resolve the request path first and refuse anything that collapses outside its root, using the same lexical containment the preview asset route already applies — bundle/project assets behind symlinked directories keep being served, only dot-segment escapes are rejected. Route-level pathSafety tests pin the behavior in both packages, including the Windows backslash shape and the symlinked-asset cases from the descriptor-pinning regressions. Validated: new containment suites fail on the prior handlers (200 + marker file content served from outside the root) and pass with the guards; cli server (140), engine services (1024+, matching the main baseline incl. pre-existing browserManager failures), typecheck, oxlint, oxfmt and the full workspace build all clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The embedded Studio server and the engine's render file server both took the raw request path, joined it onto their root directory, and opened whatever landed there. Nothing checked that the result stayed inside the root, so a request carrying percent-encoded separators or dot segments could name a file anywhere on the disk and receive its bytes. This change resolves the request path first and answers 404 for anything that collapses outside the root being served. Two handlers changed and two route-level test suites were added; no public API or serving behavior for legitimate assets is affected.
Why
Hono decodes percent escapes while it routes, and that decode is where the gap opens. On Windows an encoded backslash (
%5c) arrives at the handler as a real path separator, so/assets/..%5c..%5csecrets.txtwalks out of the Studio bundle directory, and the same shape works against the engine file server'sprojectDirandcompiledDir. Browsers normalize dot segments before sending and never emit%5c, so ordinary Studio usage and capture sessions never produce these requests. The reachable senders are everything else that can touch the bound port: any local process, another user's process on a shared machine reaching the render server, and LAN peers whenever the preview server is bound to0.0.0.0, which is a documented supported mode. Each of them can read files with the CLI user's permissions.A realpath-based containment like the repo's
resolveWithinProjectwould be the wrong tool here. Bundle and project assets are allowed to sit behind symlinked directories, and #3725 and #3728 deliberately preserved that behavior while hardening these same servers. What separates an attack from that supported workflow is lexical: dot segments collapsing upward through... So the guard rejects escape-by-collapse and keeps serving symlinked assets exactly as before, which keeps the fix consistent with the tradeoff the preview asset route already documents.How
Both handlers resolve the request path against their root and refuse the request when the resolved path lands outside it:
packages/cli/src/server/studioServer.ts—serveStudioStaticFile, which backs the/assets/*,/icons/*and/favicon.svgSPA routes, now checks the resolved path before opening it.packages/engine/src/services/fileServer.ts— theGET /*handler checks both roots it serves,compiledDirwhen present andprojectDir.The check is
isWithinProjectRootfrom@hyperframes/parsers/asset-resolution, the same helper the preview asset route uses. It compares lexically, so an in-root symlinked or junctioned asset directory still resolves and still serves, while../collapse is rejected. Failures return the same 404 the handlers already used for missing files, so error responses do not leak whether a path escaped or simply did not exist.The tests pin the behavior from the attacker's side.
fileServer.pathSafety.test.tsstarts a real listening server and fetches over HTTP, since that is the only faithful way to reproduce raw request shapes; it covers/..%2f,/%2e%2e%2f, a nested%2fhop,%5c, and repeated%5cvariants, asserting that an outside file's contents never come back. On the previous handlers those requests return 200 with the file's bytes.studioServer.pathSafety.test.tsdoes the equivalent through the Studio app router with a marker file one level above the bundle root. Both suites also keep the positive cases green: in-root assets serve normally, the SPA shell still renders, cache headers are unchanged, and the symlinked-asset cases from the descriptor-pinning regressions still pass.Test plan
packages/cli/src/serverpasses in full (142 tests) andpackages/engine/src/servicesmatches the main baseline, including the pre-existingbrowserManagerfailures unrelated to this changeoxlint,oxfmt --checkand the workspace build all pass