Skip to content

Commit 0dced88

Browse files
committed
fix(player): fail closed on runtime data delivery
1 parent 224caf8 commit 0dced88

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

packages/player/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ player.iframeElement; // HTMLIFrameElement (read-only)
134134

135135
## Advanced: iframe access
136136

137-
The composition runs inside a sandboxed `<iframe>` in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its `__player` / `__timelines` runtime objects, use the `iframeElement` getter:
137+
The composition runs inside a sandboxed `<iframe>` in the player's Shadow DOM. The default sandbox includes `allow-same-origin` for editor, recorder, and custom-timeline integrations that inspect the composition DOM. That is a trusted-content mode, not an isolation boundary: same-origin composition code can reach the embedding page.
138+
139+
For read-only or message-bridge integrations, set `sandbox-origin="opaque"`. This removes `allow-same-origin` while retaining scripts, and prevents the composition from reading unrelated parent DOM. Direct `contentDocument`, `__player`, and `__timelines` access is intentionally unavailable in that mode.
140+
141+
If you are building a trusted editor integration that needs direct access, use the `iframeElement` getter:
138142

139143
```js
140144
const player = document.querySelector("hyperframes-player");

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,7 +2497,54 @@ describe("HyperframesPlayer retained runtime data", () => {
24972497
expect(player.iframeElement.referrerPolicy).toBe("no-referrer");
24982498
});
24992499

2500+
it("supports an opaque-origin sandbox for hosts that do not need direct iframe DOM access", () => {
2501+
player.setAttribute("sandbox-origin", "opaque");
2502+
expect(player.iframeElement.sandbox.contains("allow-scripts")).toBe(true);
2503+
expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(false);
2504+
expect(player.iframeElement.sandbox.contains("allow-top-navigation")).toBe(false);
2505+
2506+
player.removeAttribute("sandbox-origin");
2507+
expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(true);
2508+
});
2509+
25002510
it("rejects payloads that structuredClone cannot transfer", () => {
25012511
expect(() => player.setRuntimeData("captions", () => undefined)).toThrow();
25022512
});
2513+
2514+
it("fails closed when structuredClone is unavailable", () => {
2515+
const original = globalThis.structuredClone;
2516+
Object.defineProperty(globalThis, "structuredClone", {
2517+
configurable: true,
2518+
value: undefined,
2519+
});
2520+
try {
2521+
expect(() => player.setRuntimeData("captions", { words: ["unsafe"] })).toThrow(
2522+
/requires structuredClone support/,
2523+
);
2524+
player._onMessage(readyMessage());
2525+
expect(runtimeCalls()).toHaveLength(0);
2526+
} finally {
2527+
Object.defineProperty(globalThis, "structuredClone", {
2528+
configurable: true,
2529+
value: original,
2530+
});
2531+
}
2532+
});
2533+
2534+
it("reports postMessage delivery failures instead of silently dropping runtime data", () => {
2535+
player._onMessage(readyMessage());
2536+
postSpy.mockImplementation(() => {
2537+
throw new DOMException("payload cannot be cloned", "DataCloneError");
2538+
});
2539+
const errors: CustomEvent[] = [];
2540+
player.addEventListener("runtimedataerror", (event) => errors.push(event as CustomEvent));
2541+
2542+
player.setRuntimeData("captions", { words: ["value"] });
2543+
2544+
expect(errors).toHaveLength(1);
2545+
expect(errors[0]?.detail).toMatchObject({
2546+
channel: "captions",
2547+
message: "payload cannot be cloned",
2548+
});
2549+
});
25032550
});

packages/player/src/hyperframes-player.ts

Lines changed: 35 additions & 4 deletions
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 SANDBOX_ORIGIN_ATTR = "sandbox-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+
SANDBOX_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._applySandboxOriginPolicy();
166169
this.resizeObserver.observe(this);
167170
window.addEventListener("message", this._onMessage);
168171
this.iframe.addEventListener("load", this._onIframeLoad);
@@ -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 SANDBOX_ORIGIN_ATTR:
225+
this._applySandboxOriginPolicy();
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
@@ -275,6 +281,15 @@ class HyperframesPlayer extends HTMLElement {
275281
}
276282
}
277283

284+
private _applySandboxOriginPolicy(): void {
285+
const policy = this.getAttribute(SANDBOX_ORIGIN_ATTR);
286+
if (policy === "opaque") {
287+
this.iframe.sandbox.remove("allow-same-origin");
288+
return;
289+
}
290+
this.iframe.sandbox.add("allow-same-origin");
291+
}
292+
278293
/**
279294
* The inner `<iframe>` rendering the composition. Use this when integrating
280295
* with tools that need `contentWindow` — `.contentWindow` on the
@@ -395,7 +410,12 @@ class HyperframesPlayer extends HTMLElement {
395410
if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) {
396411
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
397412
}
398-
const retained = typeof structuredClone === "function" ? structuredClone(payload) : payload;
413+
if (typeof structuredClone !== "function") {
414+
throw new Error(
415+
"HyperFrames runtime data requires structuredClone support; refusing an unverified payload",
416+
);
417+
}
418+
const retained = structuredClone(payload);
399419
this._runtimeData.set(channel, retained);
400420
this._deliverRuntimeData(channel, retained);
401421
}
@@ -529,7 +549,7 @@ class HyperframesPlayer extends HTMLElement {
529549
else this.removeAttribute("loop");
530550
}
531551

532-
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
552+
private _sendControl(action: string, extra: Record<string, unknown> = {}): boolean {
533553
try {
534554
this.iframe.contentWindow?.postMessage(
535555
{
@@ -541,8 +561,19 @@ class HyperframesPlayer extends HTMLElement {
541561
},
542562
"*",
543563
);
544-
} catch {
545-
/* cross-origin */
564+
return true;
565+
} catch (error) {
566+
if (action === "set-runtime-data" || action === "clear-runtime-data") {
567+
this.dispatchEvent(
568+
new CustomEvent("runtimedataerror", {
569+
detail: {
570+
channel: extra["channel"],
571+
message: error instanceof Error ? error.message : String(error),
572+
},
573+
}),
574+
);
575+
}
576+
return false;
546577
}
547578
}
548579

0 commit comments

Comments
 (0)