diff --git a/.github/workflows/player-perf.yml b/.github/workflows/player-perf.yml index 757f531fb7..68ed6337cd 100644 --- a/.github/workflows/player-perf.yml +++ b/.github/workflows/player-perf.yml @@ -107,6 +107,13 @@ jobs: if: matrix.shard == 'parity' uses: ./.github/actions/install-ffmpeg-linux + - name: Verify opaque-origin boundary (load shard only) + if: matrix.shard == 'load' + working-directory: packages/player + env: + PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + run: bun run test:browser-security + - name: Run player perf — ${{ matrix.shard }} (measure mode) working-directory: packages/player env: diff --git a/packages/player/README.md b/packages/player/README.md index 0627e4e074..39a941bc1d 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -124,6 +124,7 @@ player.ready; // boolean (read-only) player.playbackRate; // number (read/write) player.muted; // boolean (read/write) player.audioLocked; // boolean (read/write) — force-mute + hide volume controls +player.opaqueOrigin; // boolean (read/write) — isolates the composition in an opaque origin player.loop; // boolean (read/write) player.shaderCaptureScale; // number (read/write) player.shaderLoading; // "composition" | "player" | "none" (read/write) @@ -145,6 +146,20 @@ iframe.contentDocument.querySelectorAll("[data-composition-id]"); iframe.contentWindow.__timelines; ``` +### Isolate untrusted `srcdoc` HTML + +For untrusted composition HTML, add `opaque-origin` before setting `src` or `srcdoc`: + +```html + +``` + +This removes `allow-same-origin`, so composition scripts run in an opaque origin and cannot access the embedding page. Playback and controls continue over the player’s `postMessage` bridge, but direct `iframeElement.contentDocument` access and mobile parent-media support are unavailable. Changing `opaqueOrigin` on a connected player reloads its composition. + +```js +player.opaqueOrigin = true; +``` + This is the canonical way to bridge the player into tools like [`@hyperframes/studio`](../studio). The studio exports a `resolveIframe` helper that works with both iframe refs and web-component refs: ```ts diff --git a/packages/player/package.json b/packages/player/package.json index 31be057abe..39a3680b86 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -31,6 +31,7 @@ "build": "tsup && node scripts/verify-runtime-pin.mjs", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json", "test": "vitest run", + "test:browser-security": "bun run tests/browser/opaque-origin.ts", "perf": "bun run tests/perf/index.ts" }, "dependencies": { diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 8b7fadc7a5..86b3e24728 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1448,6 +1448,7 @@ describe("HyperframesPlayer srcdoc attribute", () => { type PlayerInternal = HTMLElement & { iframe: HTMLIFrameElement; _ready: boolean; + opaqueOrigin: boolean; }; beforeEach(async () => { @@ -1462,6 +1463,7 @@ describe("HyperframesPlayer srcdoc attribute", () => { | undefined; expect(ctor).toBeDefined(); expect(ctor!.observedAttributes).toContain("srcdoc"); + expect(ctor!.observedAttributes).toContain("opaque-origin"); }); it("forwards an initial srcdoc attribute to the iframe on connect", () => { @@ -1478,6 +1480,8 @@ describe("HyperframesPlayer srcdoc attribute", () => { // parse. The composition itself must still arrive intact. expect(player.iframe.getAttribute("srcdoc")).toContain("hello"); expect(player.iframe.getAttribute("srcdoc")).toContain("hyperframe.runtime.iife.js"); + expect(player.iframe.sandbox.contains("allow-scripts")).toBe(true); + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true); player.remove(); }); @@ -1518,12 +1522,15 @@ describe("HyperframesPlayer srcdoc attribute", () => { // setting src afterwards actually navigates to that URL. const player = document.createElement("hyperframes-player") as PlayerInternal; player.setAttribute("srcdoc", ""); + player.setAttribute("src", "/api/projects/foo/preview"); document.body.appendChild(player); expect(player.iframe.hasAttribute("srcdoc")).toBe(true); + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true); player.removeAttribute("srcdoc"); expect(player.iframe.hasAttribute("srcdoc")).toBe(false); + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true); player.remove(); }); @@ -1556,6 +1563,33 @@ describe("HyperframesPlayer srcdoc attribute", () => { // srcdoc carries the runtime now; what matters here is that both // attributes are present so the browser can arbitrate. expect(player.iframe.getAttribute("srcdoc")).toContain(""); + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true); + + player.remove(); + }); + + it("isolates an opaque-origin srcdoc composition even when srcdoc is set first", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("srcdoc", ""); + player.setAttribute("opaque-origin", ""); + document.body.appendChild(player); + + expect(player.iframe.sandbox.contains("allow-scripts")).toBe(true); + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(false); + + player.remove(); + }); + + it("reloads a live composition when opaque-origin changes", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("srcdoc", ""); + document.body.appendChild(player); + + player.opaqueOrigin = true; + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(false); + + player.opaqueOrigin = false; + expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true); player.remove(); }); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 94d1a4d277..e54256eb60 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -29,6 +29,7 @@ import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol"; // production browsers. const MIN_PLAYBACK_RATE = 0.1; const MAX_PLAYBACK_RATE = 5; +const OPAQUE_ORIGIN_ATTR = "opaque-origin"; export type ColorGradingTarget = | string @@ -70,6 +71,7 @@ class HyperframesPlayer extends HTMLElement { "poster", "playback-rate", "audio-src", + OPAQUE_ORIGIN_ATTR, SHADER_CAPTURE_SCALE_ATTR, SHADER_LOADING_ATTR, ]; @@ -163,6 +165,7 @@ class HyperframesPlayer extends HTMLElement { } connectedCallback() { + this._applyOpaqueOriginPolicy(); this.resizeObserver.observe(this); window.addEventListener("message", this._onMessage); this.iframe.addEventListener("load", this._onIframeLoad); @@ -203,7 +206,7 @@ class HyperframesPlayer extends HTMLElement { } // fallow-ignore-next-line complexity - attributeChangedCallback(name: string, _old: string | null, val: string | null) { + attributeChangedCallback(name: string, oldVal: string | null, val: string | null) { switch (name) { case "src": if (val) { @@ -218,6 +221,9 @@ class HyperframesPlayer extends HTMLElement { if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val); else this.iframe.removeAttribute("srcdoc"); break; + case OPAQUE_ORIGIN_ATTR: + this._applyOpaqueOriginPolicy(this.isConnected && oldVal !== val); + break; // Reject NaN/zero/negative dimensions the same way the composition // probe does (a typo like width="abc" or width="0" would otherwise // reach scaleIframeToFit as scale(NaN) or a division by zero and @@ -284,6 +290,36 @@ class HyperframesPlayer extends HTMLElement { return this.iframe; } + private _applyOpaqueOriginPolicy(reloadActiveDocument = false): void { + if (this.hasAttribute(OPAQUE_ORIGIN_ATTR)) { + this.iframe.sandbox.remove("allow-same-origin"); + } else { + this.iframe.sandbox.add("allow-same-origin"); + } + if (reloadActiveDocument) this._reloadForOpaqueOriginPolicy(); + } + + private _reloadForOpaqueOriginPolicy(): void { + this._ready = false; + this._runtimeBridgeReady = false; + const srcdoc = this.getAttribute("srcdoc"); + if (srcdoc !== null) { + this.iframe.srcdoc = prepareSrcdocForElement(this, srcdoc); + return; + } + const src = this.getAttribute("src"); + this.iframe.src = src === null ? "about:blank" : prepareSrcForElement(this, src); + } + + get opaqueOrigin(): boolean { + return this.hasAttribute(OPAQUE_ORIGIN_ATTR); + } + + set opaqueOrigin(opaque: boolean) { + if (opaque) this.setAttribute(OPAQUE_ORIGIN_ATTR, ""); + else this.removeAttribute(OPAQUE_ORIGIN_ATTR); + } + /** Scene list from the last-received runtime timeline message. Empty until * the composition runtime fires its first "timeline" postMessage. */ get scenes(): { id: string; start: number; duration: number }[] { diff --git a/packages/player/tests/browser/opaque-origin.ts b/packages/player/tests/browser/opaque-origin.ts new file mode 100644 index 0000000000..6bc016b2b9 --- /dev/null +++ b/packages/player/tests/browser/opaque-origin.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; + +import { launchBrowser } from "../perf/runner.js"; +import { startServer } from "../perf/server.js"; + +const probeSrcdoc = ``; + +const server = startServer(); +const browser = await launchBrowser(); + +try { + const page = await browser.newPage(); + await page.goto(`${server.origin}/host.html?fixture=gsap-heavy`, { + waitUntil: "domcontentloaded", + }); + await page.evaluate((srcdoc) => { + document.querySelector("hyperframes-player")?.setAttribute("srcdoc", srcdoc); + }, probeSrcdoc); + await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 1); + assert.equal( + await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)), + true, + "the default sandbox preserves same-origin integrations", + ); + console.log("default srcdoc parent access: allowed"); + + await page.evaluate(() => { + document.querySelector("hyperframes-player")?.setAttribute("opaque-origin", ""); + }); + await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 2); + assert.equal( + await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)), + false, + "opaque-origin must isolate untrusted srcdoc from the embedding page", + ); + console.log("opaque-origin srcdoc parent access: blocked"); + + await page.evaluate(() => { + document.querySelector("hyperframes-player")?.removeAttribute("opaque-origin"); + }); + await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 3); + assert.equal( + await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)), + true, + "removing opaque-origin restores the documented trusted mode", + ); + console.log("restored trusted srcdoc parent access: allowed"); +} finally { + await browser.close(); + await server.stop(); +} diff --git a/packages/player/tests/perf/runner.ts b/packages/player/tests/perf/runner.ts index 56282c1889..e22f93e306 100644 --- a/packages/player/tests/perf/runner.ts +++ b/packages/player/tests/perf/runner.ts @@ -44,6 +44,7 @@ declare global { __playerNavStart?: number; __playerDuration?: number; __playerError?: string; + __opaqueOriginProbeResults?: boolean[]; } } diff --git a/packages/player/tests/perf/server.ts b/packages/player/tests/perf/server.ts index 3cbedb5881..057c68958e 100644 --- a/packages/player/tests/perf/server.ts +++ b/packages/player/tests/perf/server.ts @@ -102,6 +102,12 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri window.__playerReady = false; window.__playerReadyAt = null; window.__playerNavStart = performance.timeOrigin + performance.now(); + window.__opaqueOriginProbeResults = []; + window.addEventListener("message", function (event) { + if (event.data && event.data.source === "hf-opaque-origin-probe") { + window.__opaqueOriginProbeResults.push(event.data.canAccessParent === true); + } + }); const player = document.getElementById("player"); player.addEventListener("ready", function (event) { window.__playerReady = true;