mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
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:
co-authored by
Claude Opus 5.5
parent
5912d300a5
commit
5f92daab6d
@@ -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. -->
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user