Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/player-perf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
<hyperframes-player srcdoc="…" opaque-origin></hyperframes-player>
```

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
Expand Down
1 change: 1 addition & 0 deletions packages/player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
34 changes: 34 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ describe("HyperframesPlayer srcdoc attribute", () => {
type PlayerInternal = HTMLElement & {
iframe: HTMLIFrameElement;
_ready: boolean;
opaqueOrigin: boolean;
};

beforeEach(async () => {
Expand All @@ -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", () => {
Expand All @@ -1478,6 +1480,8 @@ describe("HyperframesPlayer srcdoc attribute", () => {
// parse. The composition itself must still arrive intact.
expect(player.iframe.getAttribute("srcdoc")).toContain("<body>hello</body>");
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();
});
Expand Down Expand Up @@ -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", "<!doctype html><html></html>");
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();
});
Expand Down Expand Up @@ -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("<html>");
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", "<!doctype html><html></html>");
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", "<!doctype html><html></html>");
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();
});
Expand Down
38 changes: 37 additions & 1 deletion packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +71,7 @@ class HyperframesPlayer extends HTMLElement {
"poster",
"playback-rate",
"audio-src",
OPAQUE_ORIGIN_ATTR,
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 }[] {
Expand Down
55 changes: 55 additions & 0 deletions packages/player/tests/browser/opaque-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";

import { launchBrowser } from "../perf/runner.js";
import { startServer } from "../perf/server.js";

const probeSrcdoc = `<!doctype html><script>
let canAccessParent = false;
try { canAccessParent = window.parent.document.body !== null; } catch {}
window.parent.postMessage({ source: "hf-opaque-origin-probe", canAccessParent }, "*");
</script>`;

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();
}
1 change: 1 addition & 0 deletions packages/player/tests/perf/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ declare global {
__playerNavStart?: number;
__playerDuration?: number;
__playerError?: string;
__opaqueOriginProbeResults?: boolean[];
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/player/tests/perf/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading