From 9ba645c54f0a5c08ae3f139f9be500b6fca459b9 Mon Sep 17 00:00:00 2001
From: "heygengenesis[bot]"
<262951085+heygengenesis[bot]@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:30:08 +0000
Subject: [PATCH] fix(player): add opaque origin mode
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
---
.github/workflows/player-perf.yml | 7 +++
packages/player/README.md | 15 +++++
packages/player/package.json | 1 +
.../player/src/hyperframes-player.test.ts | 34 ++++++++++++
packages/player/src/hyperframes-player.ts | 38 ++++++++++++-
.../player/tests/browser/opaque-origin.ts | 55 +++++++++++++++++++
packages/player/tests/perf/runner.ts | 1 +
packages/player/tests/perf/server.ts | 6 ++
8 files changed, 156 insertions(+), 1 deletion(-)
create mode 100644 packages/player/tests/browser/opaque-origin.ts
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;