Skip to content

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

Open
Dextheking1 wants to merge 2 commits into
heygen-com:mainfrom
Dextheking1:fix/studio-api-cross-origin-writes
Open

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

Conversation

@Dextheking1

Copy link
Copy Markdown

What

Non-GET routes in createStudioApi now 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 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 when the Origin host does not match the request Host, when the Origin is unparseable (including the opaque literal null), 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

  • Unit tests added/updated: 8 new tests in 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.
  • 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 (if applicable): no docs change, the only behavior change is rejecting cross-origin writes.

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

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.

This branch has not been deployed

No deployments
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