fix: restrict runtime and playground message dispatch (#3816)

This commit is contained in:
James Russo
2026-09-09 13:56:58 -04:00
committed by GitHub
parent caa72de355
commit 05d7261052
4 changed files with 119 additions and 33 deletions
+21
View File
@@ -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);
+35 -32
View File
@@ -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);
@@ -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);
+58
View File
@@ -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 = "";
}
});
+5 -1
View File
@@ -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);
}