Skip to content
Merged
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
21 changes: 21 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ function makeControlMessage(action: string, extra?: Record<string, unknown>) {
}

describe("installRuntimeControlBridge", () => {
it("ignores inherited and unknown action names from an authorized sender", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
try {
for (const action of [
"__proto__",
"constructor",
"hasOwnProperty",
"__defineGetter__",
"toString",
"unknown",
]) {
expect(() => handler(makeControlMessage(action))).not.toThrow();
}
expect(deps.onPlay).not.toHaveBeenCalled();
handler(makeControlMessage("play"));
expect(deps.onPlay).toHaveBeenCalledOnce();
} finally {
window.removeEventListener("message", handler);
}
});
it("rejects foreign and null senders before dispatching controls", () => {
const foreign = document.createElement("iframe");
document.body.append(foreign);
Expand Down
67 changes: 35 additions & 32 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,37 +54,40 @@ type ControlHandler = (data: BridgeControlData, deps: BridgeDeps) => void;
// Per-action dispatchers. Splitting the handler into a lookup table keeps the
// top-level message listener trivial (one map lookup), and each action's logic
// becomes individually testable / inheritable for fallow's CRAP analysis.
const CONTROL_HANDLERS: Record<string, ControlHandler> = {
play: (_d, deps) => deps.onPlay(),
pause: (_d, deps) => deps.onPause(),
"stop-media": (_d, deps) => deps.onStopMedia(),
seek: (data, deps) => deps.onSeek(resolveSeekTimeSeconds(data, deps), data.seekMode ?? "commit"),
tick: (_d, deps) => deps.onTick(),
"set-muted": (data, deps) => deps.onSetMuted(Boolean(data.muted)),
"set-volume": (data, deps) =>
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
"set-native-media-sync-disabled": (data, deps) =>
deps.onSetNativeMediaSyncDisabled(Boolean(data.disabled)),
"set-web-audio-media-disabled": (data, deps) =>
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
"set-color-grading": (data, deps) =>
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
"set-color-grading-compare": (data, deps) =>
deps.onSetColorGradingCompare(data.target ?? null, data.compare ?? null),
"enable-pick-mode": (_d, deps) => deps.onEnablePickMode(),
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
"flash-elements": (data) => handleFlashElements(data),
"set-runtime-data": (data, deps) => {
if (typeof data.channel === "string")
deps.onSetRuntimeData?.(data.channel, data.payload, data.requestId);
},
"clear-runtime-data": (data, deps) => {
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel, data.requestId);
},
};
const CONTROL_HANDLERS = new Map<string, ControlHandler>(
Object.entries({
play: (_d, deps) => deps.onPlay(),
pause: (_d, deps) => deps.onPause(),
"stop-media": (_d, deps) => deps.onStopMedia(),
seek: (data, deps) =>
deps.onSeek(resolveSeekTimeSeconds(data, deps), data.seekMode ?? "commit"),
tick: (_d, deps) => deps.onTick(),
"set-muted": (data, deps) => deps.onSetMuted(Boolean(data.muted)),
"set-volume": (data, deps) =>
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
"set-native-media-sync-disabled": (data, deps) =>
deps.onSetNativeMediaSyncDisabled(Boolean(data.disabled)),
"set-web-audio-media-disabled": (data, deps) =>
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
"set-color-grading": (data, deps) =>
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
"set-color-grading-compare": (data, deps) =>
deps.onSetColorGradingCompare(data.target ?? null, data.compare ?? null),
"enable-pick-mode": (_d, deps) => deps.onEnablePickMode(),
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
"flash-elements": (data) => handleFlashElements(data),
"set-runtime-data": (data, deps) => {
if (typeof data.channel === "string")
deps.onSetRuntimeData?.(data.channel, data.payload, data.requestId);
},
"clear-runtime-data": (data, deps) => {
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel, data.requestId);
},
} satisfies Record<string, ControlHandler>),
);

function resolveSeekTimeSeconds(data: BridgeControlData, deps: BridgeDeps): number {
const explicitSeconds = Number(data.timeSeconds);
Expand Down Expand Up @@ -129,7 +132,7 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
if (rejectUnsupportedProtocol(data)) return;
const action = data.action;
if (typeof action !== "string") return;
const fn = CONTROL_HANDLERS[action];
const fn = CONTROL_HANDLERS.get(action);
if (fn) fn(data, deps);
};
window.addEventListener("message", handler);
Expand Down
58 changes: 58 additions & 0 deletions packages/sdk-playground/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import pageHtml from "../index.html?raw";
import { expect, it, vi } from "vitest";

// Keep initialization at the asynchronous persistence boundary while exercising
// the real static UI and its registered message listener.
vi.mock("./fileAdapter.js", () => ({
createFileAdapter: () => new Promise(() => {}),
}));

it("accepts only current-preview messages with registered string types", async () => {
document.documentElement.innerHTML = pageHtml;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
},
);
const listeners = vi.spyOn(window, "addEventListener");
try {
await import("./main.js");
const handler = listeners.mock.calls.find(([type]) => type === "message")?.[1];
if (typeof handler !== "function") throw new Error("Message listener not installed");
const frame = document.getElementById("preview-frame") as HTMLIFrameElement;
const original = frame.contentWindow;
const send = (source: MessageEventSource | null, data: unknown) =>
handler.call(window, new MessageEvent("message", { source, data }));
send(original, { type: "hf:duration", duration: 12 });
expect(document.getElementById("tl-dur")!.textContent).toBe("12.0s");
for (const source of [window, null]) send(source, { type: "hf:duration", duration: 99 });
expect(document.getElementById("tl-dur")!.textContent).toBe("12.0s");
for (const type of [
"__proto__",
"constructor",
"hasOwnProperty",
"__defineGetter__",
"toString",
"unknown",
["hf:duration"],
null,
]) {
expect(() => send(original, { type, duration: 99 })).not.toThrow();
}
expect(document.getElementById("tl-dur")!.textContent).toBe("12.0s");
const replacement = document.createElement("iframe");
replacement.id = frame.id;
frame.replaceWith(replacement);
send(original, { type: "hf:duration", duration: 99 });
expect(document.getElementById("tl-dur")!.textContent).toBe("12.0s");
send(replacement.contentWindow, { type: "hf:time", time: 3 });
expect(document.getElementById("tl-time")!.textContent).toBe("3.0s");
window.removeEventListener("message", handler);
} finally {
listeners.mockRestore();
vi.unstubAllGlobals();
document.documentElement.innerHTML = "";
}
});
6 changes: 5 additions & 1 deletion packages/sdk-playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,7 +1357,11 @@ const MSG_HANDLERS: Record<string, (data: any) => void> = {
};

function onWindowMessage(e: MessageEvent) {
const handler = e.data && MSG_HANDLERS[e.data.type];
const frame = getFrame()?.contentWindow;
if (!frame || e.source !== frame) return;
const type = e.data?.type;
if (typeof type !== "string" || !Object.hasOwn(MSG_HANDLERS, type)) return;
const handler = MSG_HANDLERS[type];
if (handler) handler(e.data);
}

Expand Down
Loading