Skip to content

Commit b5d9a12

Browse files
authored
fix(web): break redirect-loop scripts that freeze the HTML preview (#5469)
* fix(web): break redirect-loop scripts that freeze the HTML preview A generated or hand-edited artifact carrying a self-redirecting directive β€” most reliably a `<meta http-equiv="refresh">` that reloads the same document, or a cycle of meta refreshes (A -> B -> A -> ...) β€” reloads the preview iframe forever and pegs the main thread until the whole design workspace freezes. There was no loop detection or hop limit, so the preview stayed unusable. Add an in-iframe circuit breaker (`injectPreviewRedirectGuard`, always injected by `buildSrcdoc`) that counts meta-refresh hops in `window.name` β€” the one store that survives an iframe navigating itself β€” resets the count once a full window elapses with no further refresh (timeout safeguard so a slow legitimate auto-refresh never accumulates), and once the hop budget is exceeded, or immediately for a near-instant self-refresh, strips the offending `<meta>`, stops the frame, and posts `od:redirect-loop-blocked` to the host. The host parks the srcDoc iframe on a static "loop detected" placeholder, the reliable stop the browser's unforgeable `window.location` otherwise denies. Route any self-redirecting source onto the srcDoc path (`htmlNeedsRedirectGuard`) so the guard is always present instead of the URL-load path serving the document raw and unguarded. Covered by a runtime spec that runs the real injected guard through a VM modelling a sandboxed iframe reloading itself (hop budget, window reset, immediate self-refresh kill, chain-break, host message) plus render-mode detection specs. Source: https://plane.powerformer.net/open-design/projects/49832a02-3158-4faf-bf2f-d0e39c40c7e6/issues/94709b20-f403-4ed7-a960-1e26572fa394 Generated-By: looper 0.0.0-dev (runner=worker, agent=claude-code) * fix(web): cover srcdoc redirect guard gaps * fix(web): avoid redirect guard false positives
1 parent be98224 commit b5d9a12

5 files changed

Lines changed: 739 additions & 3 deletions

File tree

β€Žapps/web/src/components/FileViewer.tsxβ€Ž

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,13 @@ import { copyToClipboard } from '../lib/copy-to-clipboard';
117117
import { buildReactComponentSrcdoc } from '../runtime/react-component';
118118
import { shouldConsumeSlideNav } from '../runtime/slide-nav';
119119
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
120-
import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc';
120+
import {
121+
buildLazySrcdocTransport,
122+
buildRedirectLoopBlockedDoc,
123+
buildSrcdoc,
124+
canActivateSrcDocTransport,
125+
PREVIEW_REDIRECT_LOOP_MESSAGE,
126+
} from '../runtime/srcdoc';
121127
import { DeckThumbnailRail } from './DeckThumbnailRail';
122128
import { parseDeckThumbnails } from '../runtime/deck-thumbnail-parser';
123129
import {
@@ -135,6 +141,7 @@ import {
135141
hasUrlModeBridge,
136142
htmlNeedsFocusGuard,
137143
htmlNeedsPoweredPreview,
144+
htmlNeedsRedirectGuard,
138145
htmlNeedsSandboxShim,
139146
parseForceInline,
140147
shouldUrlLoadHtmlPreview,
@@ -7058,6 +7065,19 @@ function HtmlViewer({
70587065
const s = routingHtmlSource;
70597066
return s != null && htmlNeedsFocusGuard(s);
70607067
}, [passiveLargeHtmlPreview, routingHtmlSource]);
7068+
// A self-redirecting artifact must render through srcDoc so buildSrcdoc's
7069+
// redirect-loop guard is present; on the raw URL-load path the iframe reloads
7070+
// itself forever and freezes the workspace (nexu-io/open-design#710).
7071+
const needsRedirectGuard = useMemo(() => {
7072+
if (passiveLargeHtmlPreview) return false;
7073+
const s = routingHtmlSource;
7074+
return s != null && htmlNeedsRedirectGuard(s);
7075+
}, [passiveLargeHtmlPreview, routingHtmlSource]);
7076+
// Set by the injected guard's `od:redirect-loop-blocked` postMessage. The
7077+
// browser makes `window.location` unforgeable, so a runaway reload can only be
7078+
// stopped host-side β€” parking the srcDoc iframe on static content below. File-
7079+
// scoped: reset whenever the file, project, or reload key changes.
7080+
const [redirectLoopBlocked, setRedirectLoopBlocked] = useState(false);
70617081
// Project file paths, for confirming root-relative asset refs
70627082
// (`/reference-assets/main.css`) against real files instead of guessing
70637083
// from path shape. `null` while the list is in flight β€” the detection memo
@@ -7106,6 +7126,7 @@ function HtmlViewer({
71067126
drawMode: drawOverlayOpen,
71077127
forceInline: (forceInline || needsSandboxShim) && !needsPowered,
71087128
needsFocusGuard: needsFocusGuard && !needsPowered,
7129+
needsRedirectGuard: needsRedirectGuard && !needsPowered,
71097130
projectRootAssetRefs,
71107131
};
71117132
const useUrlLoadPreview = shouldUrlLoadHtmlPreview(urlLoadDecision) && !manualEditRequiresSrcDoc;
@@ -7184,6 +7205,28 @@ function HtmlViewer({
71847205
useEffect(() => {
71857206
iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
71867207
}, [useUrlLoadPreview]);
7208+
// Clear a redirect-loop park whenever the artifact changes or the user hits
7209+
// reload (reloadKey bump): the previewed content is fresh, so give it a clean
7210+
// run rather than staying pinned on the "loop detected" placeholder.
7211+
useEffect(() => {
7212+
setRedirectLoopBlocked(false);
7213+
}, [projectId, file.name, reloadKey]);
7214+
// The injected redirect guard posts `od:redirect-loop-blocked` when a preview
7215+
// reloads itself past its hop budget. Only trust our own two preview frames,
7216+
// then park the srcDoc iframe on static content so the loop cannot continue.
7217+
useEffect(() => {
7218+
function onMessage(ev: MessageEvent) {
7219+
const fromPreview =
7220+
ev.source === srcDocPreviewIframeRef.current?.contentWindow ||
7221+
ev.source === urlPreviewIframeRef.current?.contentWindow;
7222+
if (!fromPreview) return;
7223+
const data = ev.data as { type?: string } | null;
7224+
if (data?.type !== PREVIEW_REDIRECT_LOOP_MESSAGE) return;
7225+
setRedirectLoopBlocked(true);
7226+
}
7227+
window.addEventListener('message', onMessage);
7228+
return () => window.removeEventListener('message', onMessage);
7229+
}, []);
71877230

71887231
// Resolve the cross-origin powered-preview URL for artifacts that need it.
71897232
// `resolved:false` means the (cached) daemon isolation probe is still in
@@ -7414,7 +7457,16 @@ function HtmlViewer({
74147457
// re-load. Direct-mount path (no #2361/#2791 postMessage race).
74157458
const useLazySrcDocTransport =
74167459
!manualEditRequiresSrcDoc && !captureModeActive && useUrlLoadPreview && !srcDocMaterialized;
7417-
const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc;
7460+
// Park on a static "loop detected" document once the guard reports a runaway
7461+
// redirect. A self-redirecting artifact is forced onto the srcDoc iframe by
7462+
// `needsRedirectGuard`, so swapping this content is the reliable stop β€” the
7463+
// placeholder carries no redirect, so the frame settles the moment it loads.
7464+
const redirectLoopBlockedDoc = useMemo(() => buildRedirectLoopBlockedDoc(), []);
7465+
const srcDocTransportContent = redirectLoopBlocked
7466+
? redirectLoopBlockedDoc
7467+
: useLazySrcDocTransport
7468+
? lazySrcDocTransport
7469+
: srcDoc;
74187470
// Materialize the srcDoc iframe the first time it actually becomes the active
74197471
// (visible) transport β€” i.e. the first Mark/Edit/Comment/Inspect entry. We do
74207472
// NOT pre-render it while hidden/idle: that ran a second live copy during

β€Žapps/web/src/components/file-viewer-render-mode.tsβ€Ž

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export interface UrlLoadDecision {
6464
* so `injectPreviewFocusGuard` can suppress the focus grab.
6565
*/
6666
needsFocusGuard?: boolean;
67+
/**
68+
* The HTML source contains a self-redirecting directive (a
69+
* `<meta http-equiv="refresh">`, or a load-time `location` navigation /
70+
* `location.reload()`) that can loop forever and freeze the preview. When
71+
* true, forces the srcDoc path so `injectPreviewRedirectGuard` (injected by
72+
* buildSrcdoc) is present to detect and break the loop.
73+
*/
74+
needsRedirectGuard?: boolean;
6775
}
6876

6977
/**
@@ -105,6 +113,10 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
105113
if (d.tweaksBridge) return false;
106114
if (d.forceInline) return false;
107115
if (d.needsFocusGuard) return false;
116+
// A self-redirecting document must go through srcDoc so buildSrcdoc's
117+
// redirect-loop guard is in place; URL-load serves it raw with no guard and
118+
// the iframe reloads itself forever (nexu-io/open-design#710).
119+
if (d.needsRedirectGuard) return false;
108120
// Root-relative project asset refs only resolve after the srcDoc pipeline
109121
// normalizes them (normalizeRootRelativeProjectAssetRefs); the URL-load
110122
// path serves the document untouched and the browser 404s each asset.
@@ -257,3 +269,40 @@ export function htmlNeedsSandboxShim(source: string): boolean {
257269
if (/<script\s[^>]*?\bsrc\s*=/i.test(source)) return true;
258270
return false;
259271
}
272+
273+
/**
274+
* Return true when the HTML source contains a self-redirecting directive that
275+
* can loop forever and freeze the preview iframe (nexu-io/open-design#710).
276+
* When true, FileViewer forces the srcDoc path so buildSrcdoc's
277+
* `injectPreviewRedirectGuard` is present to detect and break the loop β€” the
278+
* URL-load path serves the document untouched and has no such guard.
279+
*
280+
* Detection covers the two families that produce the freeze:
281+
*
282+
* 1. `<meta http-equiv="refresh">` β€” the canonical HTML redirect; a
283+
* self-target or a cycle reloads the frame endlessly.
284+
* 2. Load-time `location` navigation β€” `location.reload()`,
285+
* `location.replace(...)`, `location.assign(...)`, or assigning
286+
* `location`/`location.href`/`window.location`. Any of these run at parse
287+
* time can re-navigate the frame in a loop. External `<script src=...>`
288+
* already routes through srcDoc via `htmlNeedsSandboxShim` /
289+
* `htmlNeedsFocusGuard`, so a redirect hidden in a linked file is covered
290+
* too.
291+
*
292+
* Pure string scan over the same `source` already fetched for preview β€” no
293+
* extra I/O. Heuristic by design: a false positive just takes the (guarded,
294+
* slightly slower) srcDoc path, which is the safe direction; a false negative
295+
* is the same unguarded preview as before.
296+
*/
297+
export function htmlNeedsRedirectGuard(source: string | null | undefined): boolean {
298+
if (!source) return false;
299+
// <meta http-equiv="refresh" ...> in any attribute order / quoting.
300+
if (/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b/i.test(source)) return true;
301+
// location.reload() / location.replace(...) / location.assign(...).
302+
if (/\blocation\s*\.\s*(?:reload|replace|assign)\s*\(/i.test(source)) return true;
303+
// location.href = ... (an assignment, not a read β€” `=` not followed by `=`).
304+
if (/\blocation\s*\.\s*href\s*=[^=]/i.test(source)) return true;
305+
// window.location = ... / document.location = ... / self|top|parent.location = ...
306+
if (/\b(?:window|document|self|top|parent)\s*\.\s*location\s*=[^=]/i.test(source)) return true;
307+
return false;
308+
}

0 commit comments

Comments
Β (0)