diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index bdde120c57..f412d7100e 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -33,6 +33,27 @@ function makeControlMessage(action: string, extra?: Record) { } 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); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 8ed2ac3191..3eabddbada 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -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 = { - 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( + 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), +); function resolveSeekTimeSeconds(data: BridgeControlData, deps: BridgeDeps): number { const explicitSeconds = Number(data.timeSeconds); @@ -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); diff --git a/packages/sdk-playground/src/main.test.ts b/packages/sdk-playground/src/main.test.ts new file mode 100644 index 0000000000..06af34db89 --- /dev/null +++ b/packages/sdk-playground/src/main.test.ts @@ -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 = ""; + } +}); diff --git a/packages/sdk-playground/src/main.ts b/packages/sdk-playground/src/main.ts index 685a2f3011..aacfb01d91 100644 --- a/packages/sdk-playground/src/main.ts +++ b/packages/sdk-playground/src/main.ts @@ -1357,7 +1357,11 @@ const MSG_HANDLERS: Record 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); }