Repository navigation
fix(studio-server): reject cross-origin state-changing requests - #6128
Dextheking1 wants to merge 3 commits into
Conversation
The studio API is unauthenticated and loopback binding is the only mitigation (F-001). CORS-safelisted POSTs (text/plain, form data) are sent without a preflight, so any website open in the user's browser could create and upload project files and trigger renders while hyperframes preview runs. Add a guard in createStudioApi: non-GET requests are refused with 403 when the Origin host does not match the request Host, when the Origin is unparseable (including the opaque literal null), or when Fetch Metadata marks the request cross-site. Requests with no Origin header (curl, tests, in-process callers) and all reads are unaffected.
There was a problem hiding this comment.
Thanks for picking this up, the threat is real and the tests go through the actual API object, which is the right way in. One gap: the guard only asks whether the Origin host equals the Host header, so a page whose DNS points its own name at 127.0.0.1 sends a matching pair and gets through. The server already has the missing half in isLoopbackHost and identityAllowed, and sameOriginPost in the CLI server applies both for the open-in-app route, including a stricter Sec-Fetch-Site rule. Could this reuse that helper, lifted somewhere both can import, so there is one answer to "is this write same origin" instead of two that disagree?
Also worth knowing: no test or lint workflow has run on this PR yet, only the mergeability check.
Replace the guard's own Origin-host-equals-Host comparison with the CLI server's sameOriginPost, lifted into studio-server as helpers/requestIdentity (new ./request-identity subpath) together with isLoopbackHost and identityAllowed. The CLI imports the same definition from there; desktopRoutes and telemetryIdentity re-export so their callers and tests do not move. The old comparison let a page whose DNS name resolves to 127.0.0.1 through, because its Origin and Host agree with each other. sameOriginPost also requires the Host to be an identity the server answers to, and refuses any Sec-Fetch-Site other than same-origin. Regression tests cover the rebinding pair, a no-Origin write on such a host, and the same-site case.
|
fixed. writeOriginAllowed is gone, the guard now calls sameOriginPost. lifted it with isLoopbackHost and identityAllowed into studio-server (helpers/requestIdentity.ts, new ./request-identity subpath in the descriptor), cli imports it from there. desktopRoutes and telemetryIdentity re-export, so their callers and tests did not move. one plumbing note: when a request has no Host header it never came over the wire, so the middleware falls back to the host in the request URL. on a real server that URL is built from the Host header, so nothing changes there. new tests in createStudioApi.originGuard.test.ts: origin and host both rebind.example (a name pointing at loopback) gets a 403 and writes nothing, same with no origin on that host, and sec-fetch-site same-site no longer passes. guard suite 11/11. local runs, ci has not run on this pr:
|
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 110a300aef. The tightening is real and I could not find a way around it, but as written it also blocks every write in setups we document as supported, so I am requesting changes on that.
What I verified works. Applied to current main (it merges cleanly): the 11 guard tests pass, the full studio-server suite passes (1381), the CLI server suites pass (229), and the Studio tests that drive createStudioApi through a real fetch pass. I also probed sameOriginPost directly. On a default loopback preview, a cross-site POST, a form or beacon POST with Origin: null, a same-site request from another localhost port, and a rebinding page whose Origin and Host agree on a non-loopback name are all refused, while the Studio page itself is allowed. Every GET route in createStudioApi is a read, so skipping GET, HEAD and OPTIONS does not leave a write open. The CLI forwards /api/* with new Request(..., { headers: c.req.raw.headers }), and I checked that Node keeps Host, Origin and Sec-Fetch-Site through that, so the guard sees the real headers. Miguel's rebinding point from the first revision is resolved by reusing sameOriginPost.
The blocker: remote and proxied setups lose all writes. docs/packages/studio-server.mdx says Studio is meant to work on non-loopback origins, naming Codespaces, tunnels and access proxies, and the package is documented for hosts mounting createStudioApi in their own server. sameOriginPost was written for the telemetry identity and the desktop button, where failing closed only drops an id or hides a button. As a write guard it fails closed on all of those setups:
identityAllowedrefuses any non-loopback Host unless the CLI'sHYPERFRAMES_PREVIEW_HOSTnames it, so a Codespaces or tunnel hostname, or a third-party host's own domain, is refused.- The last line compares against
http://${host}, so any HTTPS origin fails even when the host matches. I probedhttps://localhost:3002with Hostlocalhost:3002, a tunnel that rewrites Host tolocalhost, and a Codespaces-style public name. All three return false, and withHYPERFRAMES_PREVIEW_HOST=0.0.0.0the Codespaces case is still false.
So after this lands, editing in Studio behind any HTTPS proxy returns 403 on every save, and a library user has to set a CLI env var just to make writes work.
A shape that keeps the protection without that breakage: compare Origin to the request's own origin in a scheme-aware way (accept https:// when the request arrived over TLS or via a trusted forwarded proto), and keep the Host allowlist where the bind is loopback, which is exactly where DNS rebinding is the threat. For the library, either make the allowed hosts an option on createStudioApi with this as the default for loopback, or apply the guard in the CLI's /api/* forwarder, where HYPERFRAMES_PREVIEW_HOST already means something. Either way, a test for an HTTPS same-origin write would pin it.
Also needed: the comment ratchet fails. node scripts/comment-ratchet.mjs on the merge result reports 5 problems: the 22-line block in createStudioApi.ts (share 3.5% to 19.2%, plus a new block over 12 lines), the new requestIdentity.ts at 39% against the package's 7% with two long blocks, and one added line in desktopRoutes.ts. Most of the requestIdentity.ts prose is moved, but the ratchet grades it as a new file. Cutting the middleware comment to the why in four lines, and trimming the moved docs, should clear it.
Need: yes, unauthenticated writes from any website are a real hole · Size: ok · Reuse: good, one shared sameOriginPost instead of two answers · Simpler: the guard is already small; the re-export shims in desktopRoutes.ts and telemetryIdentity.ts could become direct imports in their tests
The probe I used is plain sameOriginPost({ host, origin, fetchSite }) calls on the built dist/helpers/requestIdentity.js, if you want to reproduce.
— Rames
| const { host, origin, fetchSite } = headers; | ||
| if (!host || !identityAllowed(host)) return false; | ||
| if (fetchSite && fetchSite !== "same-origin") return false; | ||
| return origin === undefined || origin === `http://${host}`; |
There was a problem hiding this comment.
As a write guard this rejects every HTTPS origin, even a true same-origin one: https://localhost:3002 with Host localhost:3002 returns false, as does any Codespaces or tunnel origin. That was harmless when this only gated the telemetry id and the desktop button, but on the studio API it means every save returns 403 behind a TLS proxy. Please make the comparison scheme-aware and add a test for an HTTPS same-origin write.
| if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") { | ||
| return next(); | ||
| } | ||
| const allowed = sameOriginPost({ |
There was a problem hiding this comment.
createStudioApi is the published library entry point, and sameOriginPost brings in identityAllowed, which refuses any non-loopback Host unless the CLI env var HYPERFRAMES_PREVIEW_HOST names it. A host mounting this on its own domain (per docs/packages/studio-server.mdx) would get 403 on every write. Consider an allowed-hosts option on createStudioApi (defaulting to this behaviour for loopback), or doing this check in the CLI's /api/* forwarder where that env var belongs.
sameOriginPost compares the Origin against the request's own origin in a scheme-aware way: the scheme comes from the request URL, or from x-forwarded-proto when a TLS proxy supplies it, and x-forwarded-host is honored for tunnels that rewrite Host. An https origin on the same host now passes, where every write behind a TLS proxy 403d before. createStudioApi gains an allowedHosts option naming extra hosts the guard accepts, for hosts mounting the API on their own domain. Default is unchanged: loopback, or the HYPERFRAMES_PREVIEW_HOST bind. desktopRoutes drops its sameOriginPost re-export; its test imports the shared subpath directly. Comments trimmed so the comment ratchet passes. Docs note in docs/packages/studio-server.mdx. Tests: origin guard suite 17/17 (6 new: https same-origin write, forwarded proto, https origin on a plain http request, allowedHosts accept and reject, tunnel with rewritten Host). Full studio-server suite 1360 passed, 1 failed (history file-mode, fails on the base commit too in this environment). CLI server suites 226 passed. Typecheck, oxlint, oxfmt clean.
|
pushed 89011e1. scheme-aware now: sameOriginPost compares the origin against the request own scheme (request URL, or x-forwarded-proto behind a TLS proxy) and honors x-forwarded-host for tunnels that rewrite Host. https same-origin writes pass, plain http behavior unchanged. allowedHosts option on createStudioApi, for hosts mounting the API on their own domain. default is the old behavior (loopback or the preview host bind). docs note added in studio-server.mdx. desktopRoutes re-export dropped, its test imports the shared subpath directly. comments trimmed, comment ratchet passes locally now (5 changed files, clean against the merge-base). local runs:
workflows on the new head are action_required again, same as the last head, so CI has not run from here. |
Preview approval requiredThis pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit |
miguel-heygen
left a comment
There was a problem hiding this comment.
Thanks for working through both rounds. The HTTPS case and the library case from the last review are fixed: originMatches now follows the request scheme and X-Forwarded-Proto, allowedHosts lets a host mounting the API on its own domain accept writes, the docs explain it, and the comment check passes now.
One part of the earlier blocker is still open: the CLI itself. studioServer.ts calls createStudioApi(adapter) with no options, and nothing lets a hyperframes preview user pass allowedHosts. Behind Codespaces port forwarding or a tunnel the browser's origin is the public name, and hostAllowed refuses it whether the proxy keeps it in Host or moves it to X-Forwarded-Host. I called sameOriginPost with both header shapes and got false each time without allowedHosts, which is what your own test "rejects a write on that host without the allowedHosts option" pins. Main accepts those writes today, so every save in those setups would start returning 403. The Studio dev server in packages/studio/vite.config.ts has the same gap. A CLI flag or env var that feeds allowedHosts, documented next to HYPERFRAMES_PREVIEW_HOST, plus a test through the CLI's /api/* forwarder, would close it.
Smaller things:
X-Forwarded-Hostis trusted from any client, so a rebinding page can set it tolocalhost:<port>on its own same-origin request and get past the Host check; the Origin comparison is what stops it, since browsers always send Origin on writes. A test that a forgedX-Forwarded-Hostwith a non-matching Origin is refused would keep that from regressing.- The PR description still describes the first version (Origin vs Host, no docs change, 8 tests). Worth updating to cover
allowedHosts, the forwarded headers and the new./request-identitysubpath.
No test workflows have run on this branch yet; they are waiting for a maintainer to start them.
Verdict: REQUEST_CHANGES
Reasoning: The CLI and Studio dev server still have no way to accept writes from a proxied origin, so hyperframes preview behind Codespaces or a tunnel would 403 on every save where main works.
What
Non-GET routes in
createStudioApinow reject cross-origin requests. The guard compares the Origin host against the request Host and also rejects requests whose Fetch Metadata (Sec-Fetch-Site) marks them cross-site.Why
The studio API is unauthenticated, and loopback binding is the only mitigation (the F-001 note in the CLI server code). CORS-safelisted POSTs (text/plain bodies, form data) are sent without a preflight, so any website open in the user's browser could create and upload project files and trigger renders while
hyperframes previewruns.Related work
Refs the F-001 note in
packages/cli/src/server/portUtils.ts. No separate issue; self-contained fix.How
Middleware in
createStudioApi: a non-GET request gets a 403 when the Origin host does not match the request Host, when the Origin is unparseable (including the opaque literalnull), or when Sec-Fetch-Site is cross-site. Requests with no Origin header (curl, tests, in-process callers) and all reads are unaffected.Test plan
createStudioApi.originGuard.test.ts, all passing. The file is pinned to the node vitest environment on purpose: the package default (happy-dom) follows the browser fetch spec and silently drops forbidden headers such as Origin, which made the first version of the tests simulate nothing.