Repository navigation
fix(studio-server): load fonts and modules only in the Studio's own preview frames - #7553
Conversation
…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>
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>
There was a problem hiding this comment.
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.
…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>
…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>
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 sameOrigin, 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
/projects/:id/preview/~<token>/...serves exactly what/projects/:id/preview/...serves. Only on this path does an opaque frame's load getAccess-Control-Allow-Origin: null,Vary: OriginandX-Content-Type-Options: nosniff:Sec-Fetch-Dest: fontfor a.woff,.woff2,.ttfor.otffile served asfont/*;Sec-Fetch-Dest: scriptfor a.jsor.mjsfile served astext/javascript;304revalidation of such a file. A 304 has noContent-Type; the browser keeps the stored one.GET /projects/:id/frame-tokenreturns{ token }withno-store. It refuses a request whoseSec-Fetch-Siteiscross-siteorsame-site, as the thumbnail route does./api/projects/<id>/preview/the server put in the document (<base>, the root-path import map) gets~<token>/appended. They carryReferrer-Policy: no-referrer, so the token never leaves in aReferer.CompositionsTab.tsxfetches 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-facefell back to a system font, and<script type="module">never ran. A grant keyed onOrigin: nullandSec-Fetch-Destalone would reach any sandboxed frame on any page. Measured in Chrome, the Studio's frame and another site's sandboxed frame send the sameOrigin,Sec-Fetch-SiteandSec-Fetch-Mode, and the Studio's frame sends noRefereron these loads. The grant is therefore tied to a path only the Studio can name.Sec-Fetch-Destis set by the browser, and page script cannot change it.fetch()and XHR always sendempty, so composition script still cannot read project files withfetch(), 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(): nesteddata-composition-srccompositions,.cubeLUTs,caption-overrides.json, and the wait for a video's preview copy. Those requests carrySec-Fetch-Dest: emptyand 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.jsand GSAP are classic scripts and need no grant.<base>keeps it, so its relative URLs resolve outside the token path and stay ungranted, as before this PR..cssfile (url(/x.woff2)) resolves against the Studio root, as it already does in every preview.How
One middleware, registered first on
/projects/:id/preview/*:~segment of exactly 43 base64url characters and checks it against the project's token.~<token>/prefix andReferrer-Policy.Any other path passes straight through. Grants are looked up in a
Map, so an unexpectedSec-Fetch-Destvalue (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
After
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 throughcreateStudioApi.cross-siteandsame-siterequests; 404 for an unknown project.fontloads of.woff2and.ttf,scriptloads of.jsand.mjs, and a 304 revalidation of the font. The token path serves the same bytes as the plain path.Sec-Fetch-Destofempty,document,iframe,image,style,toString,constructor, or none;comp/*.js,comp/*.woff2) and a PNG 304 under a font dest;Originthat is a real site, the Studio's own, or absent./files/.<base>is the token path, no plain preview prefix is left, and it carriesReferrer-Policy: no-referrer.srcis 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:
Map(2), or thescriptentry removed (2);Referrer-Policy(1), noVary(5), nonosniff(5), no grant at all (5);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.mainsrc…/preview/comp/probe.html…/preview/~<token>/comp/probe.htmlself.origin"null""null"FontFace.statuserrorloadedfetch()of/files/index.html,/frame-token, and the font and.jsunder the tokenTypeErrorTypeErrorA sandboxed frame served by a different site (
localhost, while the Studio was on127.0.0.1), aimed at the same project:import()of a project moduleNetworkErrorTypeErrorNetworkErrorTypeErrorNetworkErrorTypeErrorvitest: studio-serverroutes/preview*,routes/files*,createStudioApi*,captureLoads,captureVideos(17 files, 580 tests); Studiocomponents/sidebar/andvite.browser.test.ts(11 files, 70 tests); CLIstudioServer.test.ts(55).tsc --noEmitfor 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-servertarball at 166941 bytes against its 164800 budget.scripts/package-size-budgets.jsonnow allows 168500, which is the measured size plus about 1% headroom. The tarball still ships onlydistand the README.Not exercised: Safari and Firefox, a headed browser, and the packaged
hyperframes previewCLI (it builds the samecreateStudioApi).— Rames Jusso
🤖 Generated with Claude Code