fix(studio): authenticate preview message senders (#3812)

This commit is contained in:
James Russo
2026-09-09 09:32:03 -04:00
committed by GitHub
parent 26916b7bdd
commit 4f77c282da
8 changed files with 155 additions and 4 deletions
+1 -1
View File
@@ -333,7 +333,7 @@ export function StudioApp() {
: null,
effectiveTimelineDuration,
});
const compositionDimensions = useCompositionDimensions();
const compositionDimensions = useCompositionDimensions(previewIframeRef);
const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
projectId,
refreshKey,
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { CaptionOverlay } from "./CaptionOverlay";
import { useCaptionStore } from "../store";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
it("only schedules caption layout for messages from its preview", () => {
const host = document.createElement("div");
const preview = document.createElement("iframe");
const foreign = document.createElement("iframe");
document.body.append(host, preview, foreign);
const root = createRoot(host);
const schedule = vi.fn(() => 1);
vi.stubGlobal("requestAnimationFrame", schedule);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
useCaptionStore.getState().setEditMode(true);
const send = (source: MessageEventSource | null) =>
act(() => {
window.dispatchEvent(new MessageEvent("message", { source, data: { source: "hf-preview" } }));
});
try {
act(() => root.render(<CaptionOverlay iframeRef={{ current: preview }} />));
schedule.mockClear();
send(foreign.contentWindow);
send(null);
expect(schedule).not.toHaveBeenCalled();
send(preview.contentWindow);
expect(schedule).toHaveBeenCalledOnce();
} finally {
act(() => root.unmount());
useCaptionStore.getState().reset();
host.remove();
preview.remove();
foreign.remove();
vi.unstubAllGlobals();
}
});
@@ -116,6 +116,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
// messages, element resize, window resize.
const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
const handleMessage = (e: MessageEvent) => {
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
const data = e.data;
if (data?.source === "hf-preview") scheduleTick();
};
@@ -139,7 +139,7 @@ export function PreviewOverlays({
}: PreviewOverlaysProps) {
const { activeCompPath, previewIframeRef } = useStudioShellContext();
const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
const compositionDimensions = useCompositionDimensions();
const compositionDimensions = useCompositionDimensions(previewIframeRef);
// Caption edit mode is entered automatically when captions are detected;
// these give the author an explicit way OUT (and back in). Without them the
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { useCaptionDetection } from "./useCaptionDetection";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
it("does not let a foreign window trigger caption activation", () => {
const host = document.createElement("div");
const preview = document.createElement("iframe");
const foreign = document.createElement("iframe");
document.body.append(host, preview, foreign);
const root = createRoot(host);
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal("fetch", fetchMock);
const params = {
projectId: "demo",
activeCompPath: "captions.html",
compIdToSrc: new Map<string, string>(),
captionEditMode: false,
captionHasSelection: false,
previewIframeRef: { current: preview },
captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
setRightCollapsed: vi.fn(),
};
function Harness() {
useCaptionDetection(params);
return null;
}
const send = (source: MessageEventSource | null) =>
act(() => {
window.dispatchEvent(
new MessageEvent("message", { source, data: { source: "hf-preview", type: "state" } }),
);
});
try {
act(() => root.render(<Harness />));
const group = preview.contentDocument!.createElement("div");
group.className = "caption-group";
preview.contentDocument!.body.append(group);
send(foreign.contentWindow);
send(null);
expect(fetchMock).not.toHaveBeenCalled();
send(preview.contentWindow);
expect(fetchMock).toHaveBeenCalledOnce();
} finally {
act(() => root.unmount());
host.remove();
preview.remove();
foreign.remove();
vi.unstubAllGlobals();
}
});
@@ -131,6 +131,7 @@ export function useCaptionDetection({
};
const handleMessage = (e: MessageEvent) => {
if (!e.source || e.source !== previewIframeRef.current?.contentWindow) return;
const data = e.data;
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
if (!acceptStudioRuntimeMessage(data)) return;
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { useCompositionDimensions } from "./useCompositionDimensions";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
it("accepts only the current preview frame and rejects foreign or missing senders", () => {
const host = document.createElement("div");
const preview = document.createElement("iframe");
const foreign = document.createElement("iframe");
document.body.append(host, preview, foreign);
const ref = { current: preview };
const root = createRoot(host);
function Harness() {
return JSON.stringify(useCompositionDimensions(ref));
}
const send = (source: MessageEventSource | null, width: number) =>
act(() => {
window.dispatchEvent(
new MessageEvent("message", {
source,
origin: window.location.origin,
data: { source: "hf-preview", type: "stage-size", width, height: 100 },
}),
);
});
try {
act(() => root.render(<Harness />));
send(foreign.contentWindow, 300);
send(null, 400);
expect(host.textContent).toBe("null");
send(preview.contentWindow, 200);
expect(host.textContent).toBe('{"width":200,"height":100}');
ref.current = foreign;
send(preview.contentWindow, 500);
expect(host.textContent).toBe('{"width":200,"height":100}');
send(foreign.contentWindow, 600);
expect(host.textContent).toBe('{"width":600,"height":100}');
} finally {
act(() => root.unmount());
host.remove();
preview.remove();
foreign.remove();
}
});
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type RefObject } from "react";
import { useMountEffect } from "./useMountEffect";
import type { CompositionDimensions } from "../components/renders/RenderQueue";
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -29,13 +29,14 @@ function readPositiveDimensions(width: unknown, height: unknown): CompositionDim
return { width: parsedWidth, height: parsedHeight };
}
export function useCompositionDimensions() {
export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement | null>) {
const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
null,
);
useMountEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
const dimensions = readCompositionSizeMessage(e.data);
if (!dimensions) return;
setCompositionDimensions((prev) =>