From 5f92daab6d0ecac30dd384e1b0f302af13fdd9ab Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:40:56 +0200 Subject: [PATCH] fix: serve the raw-app preview shell sandboxed, load a blob copy in the editor (#11554) * fix: serve the raw-app preview shell sandboxed, load a blob copy in the editor Co-Authored-By: Claude Opus 5.5 * fix: give the preview shell the deployed app wrapper's sandbox flags Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api/src/static_assets.rs | 13 +++++ .../components/raw_apps/RawAppEditor.svelte | 56 +++++++++++++++---- frontend/vite.config.js | 19 +++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 66dc4da9df..78c2b48f3b 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -100,6 +100,19 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< res = res.header("X-Robots-Tag", "noindex, nofollow"); } + // The raw-app preview shell evaluates whatever js is posted to it, so it is + // only ever served opaque-origin, with the deployed app wrapper's exact flags + // (`get_raw_app_data`). Matched on the embedded file, not the request path, + // so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy. + if path == "ui_builder/app-preview.html" { + res = res.header( + header::CONTENT_SECURITY_POLICY, + "sandbox allow-scripts allow-forms allow-popups \ + allow-popups-to-escape-sandbox allow-downloads allow-modals \ + allow-top-navigation", + ); + } + // Add Content-Security-Policy header for static assets when policy is set if !CSP_POLICY.is_empty() { if let Ok(header_value) = HeaderValue::try_from(CSP_POLICY.as_str()) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index fd0bd23c53..25b2e75a4c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -382,6 +382,30 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' + // The served shell is `CSP: sandbox` (opaque origin), but the editor reads the + // preview's DOM and feeds it builds as a same-origin document, so it loads a + // per-mount blob: copy instead — the same split as `unsandboxedRawAppHtml`, and + // like it with no ``, so the app resolves URLs as it will once deployed. + let previewShellUrl: string | undefined = $state(undefined) + onMount(() => { + let url: string | undefined + let destroyed = false + fetch(PREVIEW_SHELL_URL) + .then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return res.text() + }) + .then((html) => { + if (destroyed) return + url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) + previewShellUrl = url + }) + .catch((e) => sendUserToast(`Could not load the app preview: ${e}`, true)) + return () => { + destroyed = true + if (url) URL.revokeObjectURL(url) + } + }) let previewIframe: HTMLIFrameElement | undefined = $state(undefined) let previewIframeLoaded = $state(false) let lastBuild: { css: string; js: string } | undefined = undefined @@ -1550,15 +1574,15 @@ * realm, so only a reload drops the old bundle's timers, listeners and the * token its client captured at module load. */ function restartPreviewRealm() { - if (!lastBuild) return // nothing running yet + if (!lastBuild || !previewShellUrl) return // nothing running yet previewIframeLoaded = false - if (previewIframe) previewIframe.src = PREVIEW_SHELL_URL + if (previewIframe) previewIframe.src = previewShellUrl if (externalPreviewWindow && !externalPreviewWindow.closed) { externalPreviewReady = false // User app code can navigate this window elsewhere, which makes its // location cross-origin and unreachable — that document holds no token. try { - externalPreviewWindow.location.replace(PREVIEW_SHELL_URL) + externalPreviewWindow.location.replace(previewShellUrl) } catch (_) {} } } @@ -1671,7 +1695,11 @@ } // Scope the window name per app path so two open editors don't fight over // (or take over / close) one shared OS-level preview window. - const win = window.open(PREVIEW_SHELL_URL, `windmillRawAppPreview:${encodeURIComponent(path)}`) + if (!previewShellUrl) { + sendUserToast('The app preview is not available', true) + return + } + const win = window.open(previewShellUrl, `windmillRawAppPreview:${encodeURIComponent(path)}`) if (!win) { sendUserToast('Could not open the preview window (popup blocked?)', true) return @@ -2003,8 +2031,8 @@ return () => restores.forEach((r) => r()) } - // Capture the live preview as a PNG data URL. The preview iframe - // (/ui_builder/app-preview.html) is same-origin with no sandbox, so its rendered + // Capture the live preview as a PNG data URL. The preview iframe (the blob: copy + // of the shell) is same-origin with no sandbox, so its rendered // document is reachable and can be serialized from here. There is no native // element-screenshot API; modern-screenshot reconstructs the DOM into an SVG // foreignObject, so a WebGL canvas is only captured when its context was created @@ -2722,12 +2750,16 @@ {/snippet} - + + {#if previewShellUrl} + + {/if} {#if buildError} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 099dbe246a..5ae1e390e3 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -210,6 +210,17 @@ function needsCrossOriginIsolation(url) { ) } +// Normalized like the static server resolves it, so `//ui_builder/...` or an +// encoded name can't reach the file without the header. +function isPreviewShell(url) { + let path = url.split('?')[0] + try { + path = decodeURIComponent(path) + } catch {} + path = path.replace(/\/+/g, '/') + return path === '/ui_builder/app-preview.html' +} + let plugin = { name: 'configure-response-headers', enforce: 'pre', @@ -219,6 +230,14 @@ let plugin = { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') } + // Mirrors `static_assets.rs`. Set before the static handler and the :4000 + // proxy fallback alike, so both paths serve the shell sandboxed. + if (isPreviewShell(req.url ?? '')) { + res.setHeader( + 'Content-Security-Policy', + 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation' + ) + } // CORP on everything so dev assets stay loadable as subresources of // isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost). res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')