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')