Skip to content

Commit 6acfb76

Browse files
miguel-heygenclaude
andcommitted
fix(studio): guard cross-origin iframe access to prevent SecurityError crashes
Wrap all contentWindow/contentDocument access and addEventListener/removeEventListener calls in try/catch across usePlaybackKeyboard, useAppHotkeys, and CompositionsTab. Prevents SecurityError from propagating to the React error boundary (white screen). Affects 1,885 crashes / 648 unique users in the last 7 days. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6101cc1 commit 6acfb76

3 files changed

Lines changed: 122 additions & 53 deletions

File tree

packages/studio/src/components/sidebar/CompositionsTab.tsx

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,33 +62,46 @@ function parsePositiveNumber(value: string | null): number | null {
6262
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
6363
}
6464

65+
// fallow-ignore-next-line complexity
6566
function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
66-
const win = iframe?.contentWindow as PreviewWindow | null;
67-
const playerDuration = win?.__player?.getDuration?.();
68-
if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
69-
return playerDuration;
67+
try {
68+
const win = iframe?.contentWindow as PreviewWindow | null;
69+
const playerDuration = win?.__player?.getDuration?.();
70+
if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
71+
return playerDuration;
72+
}
73+
} catch {
74+
/* cross-origin iframe */
7075
}
7176

72-
const doc = iframe?.contentDocument;
73-
const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
74-
return (
75-
parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
76-
parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
77-
);
77+
try {
78+
const doc = iframe?.contentDocument;
79+
const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
80+
return (
81+
parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
82+
parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
83+
);
84+
} catch {
85+
return null;
86+
}
7887
}
7988

8089
function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
81-
const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
82-
if (!player) return false;
90+
try {
91+
const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
92+
if (!player) return false;
93+
94+
if (shouldPlay) {
95+
player.play?.();
96+
return true;
97+
}
8398

84-
if (shouldPlay) {
85-
player.play?.();
99+
player.pause?.();
100+
player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
86101
return true;
102+
} catch {
103+
return false;
87104
}
88-
89-
player.pause?.();
90-
player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
91-
return true;
92105
}
93106

94107
function CompCard({

packages/studio/src/hooks/useAppHotkeys.ts

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
77
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
88
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
99

10+
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
11+
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
12+
try {
13+
return iframe?.contentWindow ?? null;
14+
} catch {
15+
return null;
16+
}
17+
}
18+
19+
/**
20+
* Handles Cmd/Ctrl+Z (undo) and Cmd/Ctrl+Shift+Z / Ctrl+Y (redo) key events.
21+
* Returns true if the event was handled, false otherwise.
22+
*/
23+
// fallow-ignore-next-line complexity
24+
function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
25+
const key = event.key.toLowerCase();
26+
if (key === "z" && !event.shiftKey) {
27+
event.preventDefault();
28+
onUndo();
29+
return true;
30+
}
31+
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
32+
event.preventDefault();
33+
onRedo();
34+
return true;
35+
}
36+
return false;
37+
}
38+
1039
// ── Types ──
1140

1241
interface EditHistoryHandle {
@@ -177,18 +206,15 @@ export function useAppHotkeys({
177206

178207
// Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
179208
if (event.metaKey || event.ctrlKey) {
180-
if (!shouldIgnoreHistoryShortcut(event.target)) {
181-
const key = event.key.toLowerCase();
182-
if (key === "z" && !event.shiftKey) {
183-
event.preventDefault();
184-
void handleUndoRef.current();
185-
return;
186-
}
187-
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
188-
event.preventDefault();
189-
void handleRedoRef.current();
190-
return;
191-
}
209+
if (
210+
!shouldIgnoreHistoryShortcut(event.target) &&
211+
handleUndoRedoKey(
212+
event,
213+
() => void handleUndoRef.current(),
214+
() => void handleRedoRef.current(),
215+
)
216+
) {
217+
return;
192218
}
193219

194220
// Cmd/Ctrl+1 — sidebar: Compositions tab
@@ -310,21 +336,33 @@ export function useAppHotkeys({
310336

311337
const syncPreviewTimelineHotkey = useCallback(
312338
(iframe: HTMLIFrameElement | null) => {
313-
const nextWindow = iframe?.contentWindow ?? null;
339+
const nextWindow = iframeContentWindow(iframe);
314340
if (previewHotkeyWindowRef.current === nextWindow) return;
315341
if (previewHotkeyWindowRef.current) {
316-
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
342+
try {
343+
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
344+
} catch {
345+
/* cross-origin iframe */
346+
}
317347
}
318348
previewHotkeyWindowRef.current = nextWindow;
319-
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
349+
try {
350+
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
351+
} catch {
352+
/* cross-origin iframe */
353+
}
320354
},
321355
[previewAppKeyDownHandler],
322356
);
323357

324358
useEffect(
325359
() => () => {
326360
if (previewHotkeyWindowRef.current) {
327-
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
361+
try {
362+
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
363+
} catch {
364+
/* cross-origin iframe */
365+
}
328366
previewHotkeyWindowRef.current = null;
329367
}
330368
},
@@ -336,24 +374,19 @@ export function useAppHotkeys({
336374
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
337375
if (!(event.metaKey || event.ctrlKey)) return;
338376
if (shouldIgnoreHistoryShortcut(event.target)) return;
339-
const key = event.key.toLowerCase();
340-
if (key === "z" && !event.shiftKey) {
341-
event.preventDefault();
342-
void handleUndoRef.current();
343-
return;
344-
}
345-
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
346-
event.preventDefault();
347-
void handleRedoRef.current();
348-
}
377+
handleUndoRedoKey(
378+
event,
379+
() => void handleUndoRef.current(),
380+
() => void handleRedoRef.current(),
381+
);
349382
}, []);
350383

351384
const syncPreviewHistoryHotkey = useCallback(
352385
(iframe: HTMLIFrameElement | null) => {
353386
previewHistoryHotkeyCleanupRef.current?.();
354387
previewHistoryHotkeyCleanupRef.current = null;
355388

356-
const win = iframe?.contentWindow ?? null;
389+
const win = iframeContentWindow(iframe);
357390
let doc: Document | null = null;
358391
try {
359392
doc = iframe?.contentDocument ?? null;
@@ -362,10 +395,18 @@ export function useAppHotkeys({
362395
}
363396
if (!win && !doc) return;
364397

365-
win?.addEventListener("keydown", handleHistoryHotkey, true);
398+
try {
399+
win?.addEventListener("keydown", handleHistoryHotkey, true);
400+
} catch {
401+
/* cross-origin */
402+
}
366403
doc?.addEventListener("keydown", handleHistoryHotkey, true);
367404
previewHistoryHotkeyCleanupRef.current = () => {
368-
win?.removeEventListener("keydown", handleHistoryHotkey, true);
405+
try {
406+
win?.removeEventListener("keydown", handleHistoryHotkey, true);
407+
} catch {
408+
/* cross-origin */
409+
}
369410
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
370411
};
371412
},

packages/studio/src/player/hooks/usePlaybackKeyboard.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,38 @@ export function usePlaybackKeyboard({
182182
playbackKeyDownRef.current = handlePlaybackKeyDown;
183183
playbackKeyUpRef.current = handlePlaybackKeyUp;
184184

185+
// fallow-ignore-next-line complexity
185186
const attachIframeShortcutListeners = useCallback(() => {
186187
iframeShortcutCleanupRef.current?.();
187188
iframeShortcutCleanupRef.current = null;
188189

189-
const iframeWin = iframeRef.current?.contentWindow;
190-
const iframeDoc = iframeRef.current?.contentDocument;
190+
let iframeWin: Window | null = null;
191+
let iframeDoc: Document | null = null;
192+
try {
193+
iframeWin = iframeRef.current?.contentWindow ?? null;
194+
iframeDoc = iframeRef.current?.contentDocument ?? null;
195+
} catch {
196+
return;
197+
}
191198
if (!iframeWin && !iframeDoc) return;
192199

193200
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
194201
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
195-
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
196-
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
202+
try {
203+
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
204+
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
205+
} catch {
206+
/* cross-origin iframe */
207+
}
197208
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
198209
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
199210
iframeShortcutCleanupRef.current = () => {
200-
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
201-
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
211+
try {
212+
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
213+
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
214+
} catch {
215+
/* cross-origin iframe */
216+
}
202217
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
203218
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
204219
};

0 commit comments

Comments
 (0)