Skip to content

fix(studio-server): load fonts and modules only in the Studio's own preview frames - #7553

Merged
jrusso1020 merged 5 commits into
mainfrom
fix/studio-opaque-preview-fonts
Nov 11, 2026
Merged

jrusso1020 merged 5 commits into
mainfrom
fix/studio-opaque-preview-fonts

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

The Compositions hover preview can load project fonts and module scripts again, and only the Studio's own frames can. Since #7522 the hover preview runs in an opaque origin, so these loads go out with Origin: null. Every sandboxed frame sends that same Origin, so the grant can't hang on headers. This PR puts it on a per-project path that only the Studio's own pages can learn.

What

  • Token path. /projects/:id/preview/~<token>/... serves exactly what /projects/:id/preview/... serves. Only on this path does an opaque frame's load get Access-Control-Allow-Origin: null, Vary: Origin and X-Content-Type-Options: nosniff:
    • Sec-Fetch-Dest: font for a .woff, .woff2, .ttf or .otf file served as font/*;
    • Sec-Fetch-Dest: script for a .js or .mjs file served as text/javascript;
    • or a 304 revalidation of such a file. A 304 has no Content-Type; the browser keeps the stored one.
  • The plain path gets no grant. A wrong token, or another project's token, gets a 404.
  • Token. HMAC-SHA256 of the project id under a random 32-byte secret made when the server starts, base64url (256 bits). It is compared in constant time, and a restarted server mints new ones.
  • Token endpoint. GET /projects/:id/frame-token returns { token } with no-store. It refuses a request whose Sec-Fetch-Site is cross-site or same-site, as the thumbnail route does.
  • Documents on the token path are re-based under the token: every /api/projects/<id>/preview/ the server put in the document (<base>, the root-path import map) gets ~<token>/ appended. They carry Referrer-Policy: no-referrer, so the token never leaves in a Referer.
  • Studio. CompositionsTab.tsx fetches the token when a hover starts and frames the composition at /preview/~<token>/comp/.... If the token can't be fetched, it falls back to the plain path, which loads as before this PR. The resting thumbnail and the main preview are unchanged.

Why

Fidelity, and it keeps the opaque-origin hardening from #7522. In the opaque hover frame a project @font-face fell back to a system font, and <script type="module"> never ran. A grant keyed on Origin: null and Sec-Fetch-Dest alone would reach any sandboxed frame on any page. Measured in Chrome, the Studio's frame and another site's sandboxed frame send the same Origin, Sec-Fetch-Site and Sec-Fetch-Mode, and the Studio's frame sends no Referer on these loads. The grant is therefore tied to a path only the Studio can name.

Sec-Fetch-Dest is set by the browser, and page script cannot change it. fetch() and XHR always send empty, so composition script still cannot read project files with fetch(), even under the token path.

Related work

Refs #7522 (hover preview runs in an opaque origin) and #7528 (thumbnail captures run in an opaque origin). #7528 grants its capture page's loads from the trusted renderer over DevTools. Thumbnail captures still use the plain path and are unchanged.

Still not loaded in an opaque frame: what the runtime loads with fetch(): nested data-composition-src compositions, .cube LUTs, caption-overrides.json, and the wait for a video's preview copy. Those requests carry Sec-Fetch-Dest: empty and stay refused on purpose. The follow-up is to inline nested compositions into the /preview/comp/* document on the server, as the root preview's bundle does.

URLs that stay outside the token path:

  • /api/runtime.js and GSAP are classic scripts and need no grant.
  • A composition that ships its own <base> keeps it, so its relative URLs resolve outside the token path and stay ungranted, as before this PR.
  • A root path inside an external .css file (url(/x.woff2)) resolves against the Studio root, as it already does in every preview.

How

One middleware, registered first on /projects/:id/preview/*:

  1. It recognizes a ~ segment of exactly 43 base64url characters and checks it against the project's token.
  2. It sends the request without that segment back through the same API.
  3. On the way out it adds the grant headers and, for an HTML document, the ~<token>/ prefix and Referrer-Policy.

Any other path passes straight through. Grants are looked up in a Map, so an unexpected Sec-Fetch-Dest value (toString, constructor) never reaches a prototype property.

Accepted limit. The opaque frame's script can read its own URL, so a composition can hand its own project's token to another page. That page could then load that project's fonts and module scripts, and read a module's exports or draw with the font. Nothing else becomes reachable that way: no other project's files, and no project file through fetch(). A composition's own script can already draw with its fonts and run its modules.

Before

Before: the composition in an allow-scripts frame, title in the monospace fallback

After

After: the same frame, title in the project's font

The composition in a 1280x720 sandbox="allow-scripts" frame on the Studio origin, headless Chrome 153. The title uses a project @font-face. The empty lower half is where a nested composition should mount (see "Still not loaded").

Test plan

  • Unit tests: preview.opaqueFrameLoads.test.ts, 44 tests through createStudioApi.

    • Token: 43 base64url characters, different per project; refused (403) to cross-site and same-site requests; 404 for an unknown project.
    • Granted under the token: font loads of .woff2 and .ttf, script loads of .js and .mjs, and a 304 revalidation of the font. The token path serves the same bytes as the plain path.
    • Refused under the token:
      • Sec-Fetch-Dest of empty, document, iframe, image, style, toString, constructor, or none;
      • mismatched dest and file type, including the sub-composition route (comp/*.js, comp/*.woff2) and a PNG 304 under a font dest;
      • a missing font;
      • Origin that is a real site, the Studio's own, or absent.
    • Without the project's token (plain path, guessed token, another project's token, a token of the wrong length): no grant for a font or module script, and a 404 for a guessed or foreign token. Also a 404 under an unknown project, and no grant under /files/.
    • Document under the token: <base> is the token path, no plain preview prefix is left, and it carries Referrer-Policy: no-referrer.
    • Studio: the hover frame's src is the token path, it falls back to the plain path without a token, and the existing hover and drag tests now resolve the token first.

    Mutation check. Each change below fails at least one test:

    • the origin check (3), the destination check (4), the extension check (1), the content-type check (3) or the 304 branch (1);
    • a plain-object lookup in place of the Map (2), or the script entry removed (2);
    • accepting any token (4), the same token for every project (3), a loose token pattern (1);
    • no document re-base (1), no Referrer-Policy (1), no Vary (5), no nosniff (5), no grant at all (5);
    • the token endpoint open to other sites (2), the middleware unregistered (29), the hover frame at the plain path (1).
  • Manual testing: headless Chrome 153 against the Studio Vite dev server with two projects, hovering a composition that uses a project font and a module script with a nested import.

    in the Studio's hover frame main this branch
    frame src …/preview/comp/probe.html …/preview/~<token>/comp/probe.html
    self.origin "null" "null"
    project FontFace.status error loaded
    module script and its import ran no yes
    fetch() of /files/index.html, /frame-token, and the font and .js under the token rejects TypeError rejects TypeError

    A sandboxed frame served by a different site (localhost, while the Studio was on 127.0.0.1), aimed at the same project:

    that frame used font import() of a project module text drawn with the font on a canvas
    the plain path NetworkError rejects TypeError same pixels as the fallback font
    a guessed token NetworkError rejects TypeError same pixels as the fallback font
    the other project's token NetworkError rejects TypeError same pixels as the fallback font
  • vitest: studio-server routes/preview*, routes/files*, createStudioApi*, captureLoads, captureVideos (17 files, 580 tests); Studio components/sidebar/ and vite.browser.test.ts (11 files, 70 tests); CLI studioServer.test.ts (55). tsc --noEmit for both packages, oxlint, oxfmt, fallow, the comment checks and the studio copy check are clean.

  • Documentation updated (not applicable)

  • Comments say why, not what

Package size budget: the frame-token path adds server code, and CI measured the packed @hyperframes/studio-server tarball at 166941 bytes against its 164800 budget. scripts/package-size-budgets.json now allows 168500, which is the measured size plus about 1% headroom. The tarball still ships only dist and the README.

Not exercised: Safari and Firefox, a headed browser, and the packaged hyperframes preview CLI (it builds the same createStudioApi).

— Rames Jusso

🤖 Generated with Claude Code

jrusso1020 and others added 2 commits November 11, 2026 12:04
…module scripts

A preview framed with an opaque origin, such as the Compositions hover
preview, makes its font and module-script loads in CORS mode with
`Origin: null`. Without a CORS grant they failed: the frame fell back to a
system font and module scripts never ran.

The preview asset route now answers those two loads with
`Access-Control-Allow-Origin: null`, `Vary: Origin` and
`X-Content-Type-Options: nosniff`. The grant is chosen by `Sec-Fetch-Dest`,
which the browser sets and page script cannot: `font` for a font file and
`script` for a .js/.mjs file, served with the matching content type. A
page's own `fetch()` is always `empty`, so it still gets no grant and
composition script still cannot read project files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…package share

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 113 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Narrow the opaque-frame grant to fonts. Every sandboxed frame sends the
same `Origin: null` and fetch metadata, so a CORS grant for module
scripts could not be limited to frames the Studio itself serves. Module
scripts stay unloaded in the hover preview for now; fonts keep their
grant, and `Sec-Fetch-Dest: script` loads of .js/.mjs get none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jrusso1020 jrusso1020 changed the title fix(studio-server): let opaque preview frames load project fonts and module scripts fix(studio-server): let opaque preview frames load project fonts Nov 11, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: COMMENT (needs human) · 0 blocker · 1 important · 0 nits · head 67555e2d44

The code does what the PR says, and CI is green. One policy call needs a human first: the grant lets any site read project fonts' glyphs, not only the Studio's own frames ↓ inline.

Scope

Read all 3 files end-to-end, plus createStudioApi middleware order, the preview/comp cache headers (private, no-cache + ETag, so a cached ungranted copy is revalidated and picks up the 304 grant), and captureLoads.grantCaptureLoads. It skips its own headers when the server already sent ACAO, so the two grants don't stack. Repro: headless Chromium 1237, a server answering with this PR's grant headers, and an attacker page on another origin with a sandbox="allow-scripts" srcdoc frame.

@jrusso1020
jrusso1020 marked this pull request as draft November 11, 2026 12:49
…review frames

A CORS grant to `Origin: null` reaches every sandboxed frame, whichever
page it sits in. The grant now lives on a per-project path instead:
`/projects/:id/preview/~<token>/...` serves exactly what `/preview/...`
serves, and only there do font loads (`Sec-Fetch-Dest: font`, a font file
served as `font/*`) and module-script loads (`script`, a .js/.mjs file
served as `text/javascript`) from an opaque frame get
`Access-Control-Allow-Origin: null`. The plain path gets no grant.

The token is an HMAC-SHA256 of the project id under a random per-server
secret, compared in constant time. The Studio reads it from
`/projects/:id/frame-token`, which refuses cross-site and same-site
requests, and the Compositions hover preview frames its composition at
the token path. Documents served there are re-based under the token and
carry `Referrer-Policy: no-referrer`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jrusso1020 jrusso1020 changed the title fix(studio-server): let opaque preview frames load project fonts fix(studio-server): load fonts and modules only in the Studio's own preview frames Nov 11, 2026
@jrusso1020
jrusso1020 marked this pull request as ready for review November 11, 2026 13:05
…ame tokens

The per-project frame-token path adds server code to the package, and the
packed tarball measured 166941 bytes in CI against a 164800 budget. Set the
budget to the measured size plus about 1% headroom.

— Rames Jusso

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 blocker · 0 important · 0 nits — 1 resolved, 0 open, 0 new · 67555e2d44 → b0ae36c035

@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit ffb13c3 Nov 11, 2026
128 of 129 checks passed
@jrusso1020
jrusso1020 deleted the fix/studio-opaque-preview-fonts branch November 11, 2026 14:07
@miguel-heygen miguel-heygen mentioned this pull request Nov 11, 2026
3 of 4 tasks
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.

1 participant