This commit is contained in:
heygengenesis[bot]
2026-08-30 17:03:19 +00:00
committed by GitHub
8 changed files with 156 additions and 1 deletions
+7
View File
@@ -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:
+15
View File
@@ -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
<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 players `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
+1
View File
@@ -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": {
@@ -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("<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();
});
@@ -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();
});
@@ -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();
});
+37 -1
View File
@@ -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 }[] {
@@ -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
View File
@@ -44,6 +44,7 @@ declare global {
__playerNavStart?: number;
__playerDuration?: number;
__playerError?: string;
__opaqueOriginProbeResults?: boolean[];
}
}
+6
View File
@@ -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;