Skip to content

fix(studio-server): reject cross-origin state-changing requests - #6128

Closed
benjaminkincses wants to merge 6 commits into
heygen-com:mainfrom
benjaminkincses:fix/studio-api-cross-origin-writes
Closed

benjaminkincses wants to merge 6 commits into
heygen-com:mainfrom
benjaminkincses:fix/studio-api-cross-origin-writes

Conversation

@benjaminkincses

@benjaminkincses benjaminkincses commented Oct 23, 2026 •

Copy link
Copy Markdown

What

Non-GET routes in createStudioApi reject cross-origin requests. The guard reuses the shared sameOriginPost predicate from the new @hyperframes/studio-server/request-identity subpath (lifted out of the CLI server, which now imports it from there), so the studio API and the CLI routes give one answer to "is this write same origin".

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 preview runs.

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 unless the shared predicate accepts it. The predicate requires the Host to be a server identity (loopback, the HYPERFRAMES_PREVIEW_HOST bind, a local name on a wildcard bind, or a host named in the allowedHosts option), requires Sec-Fetch-Site to be same-origin when present, and compares the Origin against the effective host and scheme, honoring X-Forwarded-Proto and X-Forwarded-Host for TLS proxies and tunnels that rewrite Host. Requests with no Origin header (curl, tests, in-process callers) and all reads are unaffected.

Hosts mounting the API on their own domain pass allowedHosts to createStudioApi. With hyperframes preview and the Studio dev server, the extra hosts come from HYPERFRAMES_PREVIEW_ALLOWED_HOSTS (comma separated), documented in docs/packages/studio-server.mdx. A refused write names the refused host and that variable in its 403 body.

Test plan

  • Unit tests added/updated: createStudioApi.originGuard.test.ts drives the real API object (21 tests): cross-origin and rebinding refusals, opaque null Origin, Fetch Metadata refusal, HTTPS same-origin, forwarded proto, a forged X-Forwarded-Host with a non-matching Origin, allowedHosts accept and reject, tunnel with rewritten Host, the refusal message, and the env var parser. 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.
  • CLI tests: studioServer.test.ts drives a tunnel write through the CLI's /api/* forwarder, refused without the env var (403 naming the host and the variable, nothing written) and accepted with it (201, file written). Full CLI server suites and the full studio-server suite pass locally, typecheck clean in cli, studio and studio-server.
  • Manual testing performed: verified against the real preview server. Before the fix, cross-origin file create (text/plain body), multipart upload, and duplicate-file all returned 201 and wrote the files. After the fix, all three return 403 and write nothing. Same-origin and no-Origin controls still return 201.
  • Documentation updated: docs/packages/studio-server.mdx covers allowedHosts, the forwarded headers, and HYPERFRAMES_PREVIEW_ALLOWED_HOSTS.

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.

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

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

Copy link
Copy Markdown
Author

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:

  • studio-server full suite: 1349 passed, 6 failed. all 6 fail on the base commit too in this environment (media codec and peak decode, file mode, rename math, sharp import timeout), none in the api area.
  • cli server suites: 13 files, 207 passed, 0 failed.
  • typecheck clean in both packages, oxlint and oxfmt clean on the changed files.
  • check:package-subpaths, check:package-cycles and check:cli-lazy-deps pass.

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

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:

  • identityAllowed refuses any non-loopback Host unless the CLI's HYPERFRAMES_PREVIEW_HOST names 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 probed https://localhost:3002 with Host localhost:3002, a tunnel that rewrites Host to localhost, and a Codespaces-style public name. All three return false, and with HYPERFRAMES_PREVIEW_HOST=0.0.0.0 the 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}`;

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.

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({

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.

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

Copy link
Copy Markdown
Author

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:

  • 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.
  • studio-server full suite: 1360 passed, 1 failed. history file-mode test, fails on the previous head too in this environment.
  • cli server suites: 226 passed, 0 failed.
  • typecheck clean in both packages, oxlint and oxfmt clean, check:package-subpaths, check:package-cycles and check:cli-lazy-deps pass.

workflows on the new head are action_required again, same as the last head, so CI has not run from here.

@mintlify

mintlify Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit 27193bb.

Approve preview

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

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-Host is trusted from any client, so a rebinding page can set it to localhost:<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 forged X-Forwarded-Host with 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-identity subpath.

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.

@benjaminkincses

Copy link
Copy Markdown
Author

build failed on the packed size budget, not the export itself. studio-server packed at 141878 against a 141718 budget. the new request-identity dist output is the extra size. bumped packed budget to 148972, the verifier suggestion (packed x 1.05). verifier passes locally now, CI rerunning.

@miguel-heygen

Copy link
Copy Markdown
Collaborator

On the open question: hyperframes preview and the Studio dev server should read extra allowed hosts from an env var documented next to HYPERFRAMES_PREVIEW_HOST (for example HYPERFRAMES_PREVIEW_ALLOWED_HOSTS, comma separated) and pass them to allowedHosts. A refused write should say which host was refused and which variable allows it. That keeps loopback safe by default, and a Codespaces or tunnel user fixes the 403 in one step instead of silently losing every save. With that and a test through the CLI's /api/* forwarder, this is ready.

Dextheking1 added 2 commits October 26, 2026 22:09
…ALLOWED_HOSTS

The CLI preview server and the Studio dev server now read the comma
separated HYPERFRAMES_PREVIEW_ALLOWED_HOSTS and pass the names to
createStudioApi as allowedHosts, so a preview behind Codespaces port
forwarding or a tunnel accepts writes from its public host. The
parsing lives in requestIdentity, next to HYPERFRAMES_PREVIEW_HOST.

A refused write now names the refused host and the variable that
allows it, instead of a bare forbidden.

Tests: guard suite covers the refusal message and a forged
X-Forwarded-Host with a non-matching Origin; the CLI suite drives a
tunnel write through the /api/* forwarder with and without the
variable set.
@benjaminkincses

Copy link
Copy Markdown
Author

pushed 27193bb.

env var is in. hyperframes preview and the Studio dev server read HYPERFRAMES_PREVIEW_ALLOWED_HOSTS (comma separated) and pass it to allowedHosts. parsing lives in requestIdentity next to HYPERFRAMES_PREVIEW_HOST, exported from the package index and the ./request-identity subpath. docs updated in studio-server.mdx, PR description updated too.

refused writes now name the host and the variable: forbidden: writes from host "demo.tunnels.example" are not allowed. Set HYPERFRAMES_PREVIEW_ALLOWED_HOSTS=demo.tunnels.example to allow it (comma separated for several hosts).

new tests:

  • guard suite 21/21: refusal message, forged X-Forwarded-Host with a non-matching Origin gets a 403 and writes nothing, env parser.
  • cli forwarder: tunnel write through /api/* is 403 without the var (nothing written), 201 with it (file written). studioServer.test.ts 44/44.
  • full studio-server suite 1991 passed, cli server suites 239 passed. typecheck clean in cli, studio, studio-server. oxlint and oxfmt clean on the changed files.
  • packed size for studio-server measured locally at 152359 against the 154437 budget, no bump needed.

workflows on the new head are the maintainer approval gate again, same as the last heads.

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

Reviewed at 27193bb105900a293f571087df3d466827e5e3ca.

The latest head resolves the earlier HTTPS and tunnel regressions. Both CLI and Studio dev hosts pass the documented allowed-host list into createStudioApi, while the shared guard still refuses cross-site and rebinding writes.

5 actual sameOriginPost helper controls pass: loopback success, cross-site refusal, rebinding refusal, allowed HTTPS tunnel success, mismatched forwarded Origin refusal. Full originGuard suite blocked at collection by borrowed core missing ./safe-path. Security hardening, not authentication: browser Host checks and same-origin checks protect local writes, local clients remain trusted. No live tunnel test. Six unsigned commits remain; root independent security pass required.

Need: yes · Size: ok · Reuse: existing implementation seam · Simpler: already minimal · Structure: fine
Risk: medium - local write-origin trust boundary.

Verdict: APPROVE
Reasoning: The latest head resolves the earlier HTTPS and tunnel regressions. Both CLI and Studio dev hosts pass the documented allowed-host list into createStudioApi, while the shared guard still refuses cross-site and rebinding writes.

Reasoning: The latest head resolves the earlier HTTPS and tunnel regressions. Both CLI and Studio dev hosts pass the documented allowed-host list into createStudioApi, while the shared guard still refuses cross-site and rebinding writes.

@jrusso1020

Copy link
Copy Markdown
Collaborator

Thank you for this, @benjaminkincses, and for working through three rounds of review. We wanted it merged, but this branch can't be updated from our side: bringing it up to date pulls in LFS objects a maintainer can't upload to a fork, and the repo's rules now refuse unsigned and merge commits. So I carried it forward in #7490. Your commits are squashed into one commit with you as the author, rebased onto current main, plus one follow-up commit that applies the same host check to reads. Closing this in favor of #7490.

— Rames Jusso

@jrusso1020 jrusso1020 closed this Nov 10, 2026
timothybrush pushed a commit to timothybrush/hyperframes that referenced this pull request Nov 10, 2026
…he preview server (heygen-com#7490)

* fix(studio-server): reject cross-origin state-changing requests

Non-GET routes in createStudioApi answer 403 unless the shared
sameOriginPost predicate (lifted into studio-server's ./request-identity
subpath) accepts the request: the Host must be a server identity, a
present Sec-Fetch-Site must be same-origin, and a present Origin must
match the effective host and scheme. Hosts behind Codespaces or tunnels
allow their public name with HYPERFRAMES_PREVIEW_ALLOWED_HOSTS, read by
`hyperframes preview` and the Studio dev server.

Squashed from heygen-com#6128 (42574a7, 110a300, 89011e1, 41c6626, 27193bb) onto
current main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cli): the preview server answers only the hosts it is reached by

Every route of `hyperframes preview` (the forwarded studio API, the SSE
stream, /__hyperframes_config and the Studio page) now answers only
loopback names, the HYPERFRAMES_PREVIEW_HOST bind, or a host listed in
HYPERFRAMES_PREVIEW_ALLOWED_HOSTS, the same list the write guard uses.
Only the Host header counts here, since a page can set X-Forwarded-Host
on its own requests and a read carries no Origin to compare it with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Dextheking1 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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.

3 participants