fix(apps): bind the sandboxed SDK handoff to the document we loaded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
Diego Imbert
2026-07-28 17:36:38 +02:00
co-authored by Claude Fable 5
parent 571bf7ebcf
commit 4f4db90bf3
3 changed files with 40 additions and 25 deletions
+10 -1
View File
@@ -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);
})();
@@ -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
}
@@ -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<string, string> | 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<string, string> {
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.