diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 87b4fe039d..cfbb8a8e2a 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -639,6 +639,9 @@ async fn get_raw_app_data( .header(http::header::CONTENT_TYPE, "text/html; charset=utf-8") .header("X-Content-Type-Options", "nosniff") .header("Cross-Origin-Resource-Policy", "cross-origin") + // This URL carries the frontend-SDK handshake nonce, so it must not + // travel to whatever the bundle navigates to or links out to. + .header("Referrer-Policy", "no-referrer") .header( http::header::CONTENT_SECURITY_POLICY, "sandbox allow-scripts allow-forms allow-popups \ @@ -843,7 +846,13 @@ fn raw_app_wrapper_html(secret: &str) -> String { loadBundle(); } }); - try { window.parent.postMessage({ type: 'windmill:ready' }, '*'); } catch (_) {} + // Echo the handshake nonce from our own URL. It proves to the embedder that + // this is the document it loaded rather than one navigated into the frame + // afterwards, which is what gates the frontend SDK credential. + try { + var hs = new URLSearchParams(window.location.search).get('wm_hs') || undefined; + window.parent.postMessage({ type: 'windmill:ready', nonce: hs }, '*'); + } catch (_) {} // Fallback for contexts that never send ctx (e.g. ctx-less rendering). setTimeout(loadBundle, 1500); })(); diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 3ab9ea5bc7..4d186f5577 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -275,9 +275,10 @@ sdkToken = undefined if (sdkScopes) { if (!hasStoredSdkConsent(viewerEmail, workspaceId ?? '', appPath ?? '', sdkScopes)) { - // Ask before the app's code runs. This is the viewer's decision - // point, not a containment boundary — an unsandboxed app runs with - // their session either way; sandbox isolation is what contains it. + // Ask before the app's code runs. For an unsandboxed app this is a + // decision point rather than a boundary — it runs with the viewer's + // session either way. For a sandboxed one the token is the only + // reach it gets, so the answer decides everything it can do. status = 'sdkPrompt' return } diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 2b07728d07..6b1255ac91 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -4,6 +4,10 @@ import type { Runnable } from './rawAppPolicy' import { getContext, onMount, untrack } from 'svelte' import { unsandboxedRawAppHtml } from './utils' + import { randomUUID } from '$lib/utils/uuid' + + // Per-mount secret proving a `windmill:ready` came from the document we loaded. + const handshakeNonce = randomUUID() interface Props { workspace: string @@ -96,9 +100,15 @@ // — the wrapper would otherwise be blocked outright, URL flag or not. const coep = new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated - ? '?wm_coep=1' + ? 'wm_coep=1&' : '' - return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}` + // The handshake nonce binds the credential reply to the document WE loaded. + // `windmill:ready` only proves which browsing context spoke, and the reply + // has to target `*` (the frame's origin is opaque), so without this any + // document navigated into that frame — including one an ancestor swapped in + // before our wrapper announced itself — could ask for the viewer's token. + // It lives in the frame's own URL, which a cross-origin ancestor cannot read. + return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html?${coep}wm_hs=${handshakeNonce}` }) // Revoke blob: URLs (unsandboxed path) when they change or on unmount. @@ -132,6 +142,8 @@ const framed = typeof window !== 'undefined' && window.parent !== window && !storageAccessible() let bundleStorage: Record | undefined = undefined let pendingReady = false + // Nonce from a `windmill:ready` we could not answer yet (storage still loading). + let pendingNonce: string | undefined = undefined function readDirect(): Record { try { @@ -151,21 +163,12 @@ } catch (_) {} } - // The token may only be handed over once per document we loaded ourselves. The - // reply is addressed to a browsing context, not a document, and it must target - // `*` because the sandboxed frame has an opaque origin — so a page the bundle - // (or a link the viewer clicked) navigated to keeps the same `contentWindow` - // and could ask for the credential again by posting `windmill:ready`. Reset - // only when WE change the iframe's src, which a navigation away does not. - let sdkHandedOff = false - $effect(() => { - iframeSrc - sdkHandedOff = false - }) - - function respondCtx() { - const sdkToken = sdkHandedOff ? undefined : sdkTokenCtx?.value - if (sdkToken) sdkHandedOff = true + /** The credential only goes to a document that proved it is the one we loaded, + * by echoing the nonce from its own URL. A document navigated into the frame + * keeps the same `contentWindow` and can post `windmill:ready` at any time — + * including before our wrapper does — but cannot read that URL. */ + function respondCtx(nonceEcho?: string) { + const sdkToken = nonceEcho === handshakeNonce ? sdkTokenCtx?.value : undefined iframe?.contentWindow?.postMessage( { type: 'windmill:ctx', @@ -202,7 +205,7 @@ bundleStorage = {} if (pendingReady) { pendingReady = false - respondCtx() + respondCtx(pendingNonce) } } }, 750) @@ -218,7 +221,7 @@ bundleStorage = data.data || {} if (pendingReady) { pendingReady = false - respondCtx() + respondCtx(pendingNonce) } return } @@ -226,13 +229,15 @@ if (event.source !== iframe?.contentWindow) return if (data?.type === 'windmill:ready') { // Hand the bundle its context + shared storage before it evaluates. + const nonceEcho = typeof data.nonce === 'string' ? data.nonce : undefined if (!framed) { bundleStorage = readDirect() - respondCtx() + respondCtx(nonceEcho) } else if (bundleStorage !== undefined) { - respondCtx() + respondCtx(nonceEcho) } else { pendingReady = true + pendingNonce = nonceEcho } } else if (data?.type === 'wm_ls_op') { // The bundle mutated localStorage — apply it to the shared store.