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 <noreply@anthropic.com>

* fix: give the preview shell the deployed app wrapper's sandbox flags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-10-06 14:40:56 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5912d300a5
commit 5f92daab6d
3 changed files with 76 additions and 12 deletions
+13
View File
@@ -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()) {
@@ -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 `<base>`, 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 @@
</div>
{/snippet}
</DraggableTabs>
<iframe
bind:this={previewIframe}
title="App preview"
src={PREVIEW_SHELL_URL}
class="w-full flex-1 block"
></iframe>
<!-- Not mounted before the shell is: a src-less iframe fires `load`
for about:blank, which would mark the preview ready. -->
{#if previewShellUrl}
<iframe
bind:this={previewIframe}
title="App preview"
src={previewShellUrl}
class="w-full flex-1 block"
></iframe>
{/if}
{#if buildError}
<!-- top-12 clears the tab bar; `before:bg-surface` backs the
Alert's translucent red; `isolate` pins the pseudo's stacking context. -->
+19
View File
@@ -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')