mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
Merge 9ba645c54f into 3337cc8990
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -44,6 +44,7 @@ declare global {
|
||||
__playerNavStart?: number;
|
||||
__playerDuration?: number;
|
||||
__playerError?: string;
|
||||
__opaqueOriginProbeResults?: boolean[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user