mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix: restrict runtime and playground message dispatch (#3816)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user