From 4f77c282dafe044ab773d8ecb5c96f6fa8b5ebf6 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 9 Sep 2026 09:32:03 -0400 Subject: [PATCH] fix(studio): authenticate preview message senders (#3812) --- packages/studio/src/App.tsx | 2 +- .../components/CaptionOverlay.sender.test.tsx | 47 ++++++++++++++++ .../captions/components/CaptionOverlay.tsx | 1 + .../src/components/nle/PreviewOverlays.tsx | 2 +- .../hooks/useCaptionDetection.sender.test.tsx | 54 +++++++++++++++++++ .../studio/src/hooks/useCaptionDetection.ts | 1 + .../hooks/useCompositionDimensions.test.tsx | 47 ++++++++++++++++ .../src/hooks/useCompositionDimensions.ts | 5 +- 8 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx create mode 100644 packages/studio/src/hooks/useCaptionDetection.sender.test.tsx create mode 100644 packages/studio/src/hooks/useCompositionDimensions.test.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index f2a9313e6d..4f915743f8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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, diff --git a/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx b/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx new file mode 100644 index 0000000000..4f62397722 --- /dev/null +++ b/packages/studio/src/captions/components/CaptionOverlay.sender.test.tsx @@ -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()); + 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(); + } +}); diff --git a/packages/studio/src/captions/components/CaptionOverlay.tsx b/packages/studio/src/captions/components/CaptionOverlay.tsx index 5681b25a26..cf1da7af16 100644 --- a/packages/studio/src/captions/components/CaptionOverlay.tsx +++ b/packages/studio/src/captions/components/CaptionOverlay.tsx @@ -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(); }; diff --git a/packages/studio/src/components/nle/PreviewOverlays.tsx b/packages/studio/src/components/nle/PreviewOverlays.tsx index 5bbbbcd6dd..05c8a06ca6 100644 --- a/packages/studio/src/components/nle/PreviewOverlays.tsx +++ b/packages/studio/src/components/nle/PreviewOverlays.tsx @@ -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 diff --git a/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx b/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx new file mode 100644 index 0000000000..36ef06c446 --- /dev/null +++ b/packages/studio/src/hooks/useCaptionDetection.sender.test.tsx @@ -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(() => {})); + vi.stubGlobal("fetch", fetchMock); + const params = { + projectId: "demo", + activeCompPath: "captions.html", + compIdToSrc: new Map(), + 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()); + 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(); + } +}); diff --git a/packages/studio/src/hooks/useCaptionDetection.ts b/packages/studio/src/hooks/useCaptionDetection.ts index e7fce63c47..b7554b174f 100644 --- a/packages/studio/src/hooks/useCaptionDetection.ts +++ b/packages/studio/src/hooks/useCaptionDetection.ts @@ -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; diff --git a/packages/studio/src/hooks/useCompositionDimensions.test.tsx b/packages/studio/src/hooks/useCompositionDimensions.test.tsx new file mode 100644 index 0000000000..ec02a8f6c5 --- /dev/null +++ b/packages/studio/src/hooks/useCompositionDimensions.test.tsx @@ -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()); + 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(); + } +}); diff --git a/packages/studio/src/hooks/useCompositionDimensions.ts b/packages/studio/src/hooks/useCompositionDimensions.ts index 49a5371496..da83c2a2ac 100644 --- a/packages/studio/src/hooks/useCompositionDimensions.ts +++ b/packages/studio/src/hooks/useCompositionDimensions.ts @@ -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) { const [compositionDimensions, setCompositionDimensions] = useState( 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) =>