Skip to content

Commit 9ba645c

Browse files
fix(player): add opaque origin mode
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
1 parent 097d901 commit 9ba645c

8 files changed

Lines changed: 156 additions & 1 deletion

File tree

.github/workflows/player-perf.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ jobs:
107107
if: matrix.shard == 'parity'
108108
uses: ./.github/actions/install-ffmpeg-linux
109109

110+
- name: Verify opaque-origin boundary (load shard only)
111+
if: matrix.shard == 'load'
112+
working-directory: packages/player
113+
env:
114+
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
115+
run: bun run test:browser-security
116+
110117
- name: Run player perf — ${{ matrix.shard }} (measure mode)
111118
working-directory: packages/player
112119
env:

packages/player/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ player.ready; // boolean (read-only)
124124
player.playbackRate; // number (read/write)
125125
player.muted; // boolean (read/write)
126126
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
127+
player.opaqueOrigin; // boolean (read/write) — isolates the composition in an opaque origin
127128
player.loop; // boolean (read/write)
128129
player.shaderCaptureScale; // number (read/write)
129130
player.shaderLoading; // "composition" | "player" | "none" (read/write)
@@ -145,6 +146,20 @@ iframe.contentDocument.querySelectorAll("[data-composition-id]");
145146
iframe.contentWindow.__timelines;
146147
```
147148

149+
### Isolate untrusted `srcdoc` HTML
150+
151+
For untrusted composition HTML, add `opaque-origin` before setting `src` or `srcdoc`:
152+
153+
```html
154+
<hyperframes-player srcdoc="" opaque-origin></hyperframes-player>
155+
```
156+
157+
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.
158+
159+
```js
160+
player.opaqueOrigin = true;
161+
```
162+
148163
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:
149164

150165
```ts

packages/player/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"build": "tsup && node scripts/verify-runtime-pin.mjs",
3232
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json",
3333
"test": "vitest run",
34+
"test:browser-security": "bun run tests/browser/opaque-origin.ts",
3435
"perf": "bun run tests/perf/index.ts"
3536
},
3637
"dependencies": {

packages/player/src/hyperframes-player.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,7 @@ describe("HyperframesPlayer srcdoc attribute", () => {
14481448
type PlayerInternal = HTMLElement & {
14491449
iframe: HTMLIFrameElement;
14501450
_ready: boolean;
1451+
opaqueOrigin: boolean;
14511452
};
14521453

14531454
beforeEach(async () => {
@@ -1462,6 +1463,7 @@ describe("HyperframesPlayer srcdoc attribute", () => {
14621463
| undefined;
14631464
expect(ctor).toBeDefined();
14641465
expect(ctor!.observedAttributes).toContain("srcdoc");
1466+
expect(ctor!.observedAttributes).toContain("opaque-origin");
14651467
});
14661468

14671469
it("forwards an initial srcdoc attribute to the iframe on connect", () => {
@@ -1478,6 +1480,8 @@ describe("HyperframesPlayer srcdoc attribute", () => {
14781480
// parse. The composition itself must still arrive intact.
14791481
expect(player.iframe.getAttribute("srcdoc")).toContain("<body>hello</body>");
14801482
expect(player.iframe.getAttribute("srcdoc")).toContain("hyperframe.runtime.iife.js");
1483+
expect(player.iframe.sandbox.contains("allow-scripts")).toBe(true);
1484+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true);
14811485

14821486
player.remove();
14831487
});
@@ -1518,12 +1522,15 @@ describe("HyperframesPlayer srcdoc attribute", () => {
15181522
// setting src afterwards actually navigates to that URL.
15191523
const player = document.createElement("hyperframes-player") as PlayerInternal;
15201524
player.setAttribute("srcdoc", "<!doctype html><html></html>");
1525+
player.setAttribute("src", "/api/projects/foo/preview");
15211526
document.body.appendChild(player);
15221527
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
1528+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true);
15231529

15241530
player.removeAttribute("srcdoc");
15251531

15261532
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
1533+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true);
15271534

15281535
player.remove();
15291536
});
@@ -1556,6 +1563,33 @@ describe("HyperframesPlayer srcdoc attribute", () => {
15561563
// srcdoc carries the runtime now; what matters here is that both
15571564
// attributes are present so the browser can arbitrate.
15581565
expect(player.iframe.getAttribute("srcdoc")).toContain("<html>");
1566+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true);
1567+
1568+
player.remove();
1569+
});
1570+
1571+
it("isolates an opaque-origin srcdoc composition even when srcdoc is set first", () => {
1572+
const player = document.createElement("hyperframes-player") as PlayerInternal;
1573+
player.setAttribute("srcdoc", "<!doctype html><html></html>");
1574+
player.setAttribute("opaque-origin", "");
1575+
document.body.appendChild(player);
1576+
1577+
expect(player.iframe.sandbox.contains("allow-scripts")).toBe(true);
1578+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(false);
1579+
1580+
player.remove();
1581+
});
1582+
1583+
it("reloads a live composition when opaque-origin changes", () => {
1584+
const player = document.createElement("hyperframes-player") as PlayerInternal;
1585+
player.setAttribute("srcdoc", "<!doctype html><html></html>");
1586+
document.body.appendChild(player);
1587+
1588+
player.opaqueOrigin = true;
1589+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(false);
1590+
1591+
player.opaqueOrigin = false;
1592+
expect(player.iframe.sandbox.contains("allow-same-origin")).toBe(true);
15591593

15601594
player.remove();
15611595
});

packages/player/src/hyperframes-player.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
2929
// production browsers.
3030
const MIN_PLAYBACK_RATE = 0.1;
3131
const MAX_PLAYBACK_RATE = 5;
32+
const OPAQUE_ORIGIN_ATTR = "opaque-origin";
3233

3334
export type ColorGradingTarget =
3435
| string
@@ -70,6 +71,7 @@ class HyperframesPlayer extends HTMLElement {
7071
"poster",
7172
"playback-rate",
7273
"audio-src",
74+
OPAQUE_ORIGIN_ATTR,
7375
SHADER_CAPTURE_SCALE_ATTR,
7476
SHADER_LOADING_ATTR,
7577
];
@@ -163,6 +165,7 @@ class HyperframesPlayer extends HTMLElement {
163165
}
164166

165167
connectedCallback() {
168+
this._applyOpaqueOriginPolicy();
166169
this.resizeObserver.observe(this);
167170
window.addEventListener("message", this._onMessage);
168171
this.iframe.addEventListener("load", this._onIframeLoad);
@@ -203,7 +206,7 @@ class HyperframesPlayer extends HTMLElement {
203206
}
204207

205208
// fallow-ignore-next-line complexity
206-
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
209+
attributeChangedCallback(name: string, oldVal: string | null, val: string | null) {
207210
switch (name) {
208211
case "src":
209212
if (val) {
@@ -218,6 +221,9 @@ class HyperframesPlayer extends HTMLElement {
218221
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
219222
else this.iframe.removeAttribute("srcdoc");
220223
break;
224+
case OPAQUE_ORIGIN_ATTR:
225+
this._applyOpaqueOriginPolicy(this.isConnected && oldVal !== val);
226+
break;
221227
// Reject NaN/zero/negative dimensions the same way the composition
222228
// probe does (a typo like width="abc" or width="0" would otherwise
223229
// reach scaleIframeToFit as scale(NaN) or a division by zero and
@@ -284,6 +290,36 @@ class HyperframesPlayer extends HTMLElement {
284290
return this.iframe;
285291
}
286292

293+
private _applyOpaqueOriginPolicy(reloadActiveDocument = false): void {
294+
if (this.hasAttribute(OPAQUE_ORIGIN_ATTR)) {
295+
this.iframe.sandbox.remove("allow-same-origin");
296+
} else {
297+
this.iframe.sandbox.add("allow-same-origin");
298+
}
299+
if (reloadActiveDocument) this._reloadForOpaqueOriginPolicy();
300+
}
301+
302+
private _reloadForOpaqueOriginPolicy(): void {
303+
this._ready = false;
304+
this._runtimeBridgeReady = false;
305+
const srcdoc = this.getAttribute("srcdoc");
306+
if (srcdoc !== null) {
307+
this.iframe.srcdoc = prepareSrcdocForElement(this, srcdoc);
308+
return;
309+
}
310+
const src = this.getAttribute("src");
311+
this.iframe.src = src === null ? "about:blank" : prepareSrcForElement(this, src);
312+
}
313+
314+
get opaqueOrigin(): boolean {
315+
return this.hasAttribute(OPAQUE_ORIGIN_ATTR);
316+
}
317+
318+
set opaqueOrigin(opaque: boolean) {
319+
if (opaque) this.setAttribute(OPAQUE_ORIGIN_ATTR, "");
320+
else this.removeAttribute(OPAQUE_ORIGIN_ATTR);
321+
}
322+
287323
/** Scene list from the last-received runtime timeline message. Empty until
288324
* the composition runtime fires its first "timeline" postMessage. */
289325
get scenes(): { id: string; start: number; duration: number }[] {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import assert from "node:assert/strict";
2+
3+
import { launchBrowser } from "../perf/runner.js";
4+
import { startServer } from "../perf/server.js";
5+
6+
const probeSrcdoc = `<!doctype html><script>
7+
let canAccessParent = false;
8+
try { canAccessParent = window.parent.document.body !== null; } catch {}
9+
window.parent.postMessage({ source: "hf-opaque-origin-probe", canAccessParent }, "*");
10+
</script>`;
11+
12+
const server = startServer();
13+
const browser = await launchBrowser();
14+
15+
try {
16+
const page = await browser.newPage();
17+
await page.goto(`${server.origin}/host.html?fixture=gsap-heavy`, {
18+
waitUntil: "domcontentloaded",
19+
});
20+
await page.evaluate((srcdoc) => {
21+
document.querySelector("hyperframes-player")?.setAttribute("srcdoc", srcdoc);
22+
}, probeSrcdoc);
23+
await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 1);
24+
assert.equal(
25+
await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)),
26+
true,
27+
"the default sandbox preserves same-origin integrations",
28+
);
29+
console.log("default srcdoc parent access: allowed");
30+
31+
await page.evaluate(() => {
32+
document.querySelector("hyperframes-player")?.setAttribute("opaque-origin", "");
33+
});
34+
await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 2);
35+
assert.equal(
36+
await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)),
37+
false,
38+
"opaque-origin must isolate untrusted srcdoc from the embedding page",
39+
);
40+
console.log("opaque-origin srcdoc parent access: blocked");
41+
42+
await page.evaluate(() => {
43+
document.querySelector("hyperframes-player")?.removeAttribute("opaque-origin");
44+
});
45+
await page.waitForFunction(() => (window.__opaqueOriginProbeResults?.length ?? 0) >= 3);
46+
assert.equal(
47+
await page.evaluate(() => window.__opaqueOriginProbeResults?.at(-1)),
48+
true,
49+
"removing opaque-origin restores the documented trusted mode",
50+
);
51+
console.log("restored trusted srcdoc parent access: allowed");
52+
} finally {
53+
await browser.close();
54+
await server.stop();
55+
}

packages/player/tests/perf/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ declare global {
4444
__playerNavStart?: number;
4545
__playerDuration?: number;
4646
__playerError?: string;
47+
__opaqueOriginProbeResults?: boolean[];
4748
}
4849
}
4950

packages/player/tests/perf/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
102102
window.__playerReady = false;
103103
window.__playerReadyAt = null;
104104
window.__playerNavStart = performance.timeOrigin + performance.now();
105+
window.__opaqueOriginProbeResults = [];
106+
window.addEventListener("message", function (event) {
107+
if (event.data && event.data.source === "hf-opaque-origin-probe") {
108+
window.__opaqueOriginProbeResults.push(event.data.canAccessParent === true);
109+
}
110+
});
105111
const player = document.getElementById("player");
106112
player.addEventListener("ready", function (event) {
107113
window.__playerReady = true;

0 commit comments

Comments
 (0)