From 3dd117fff2ffd83ae187bd619658a67a67555ead Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 28 Jul 2026 18:12:14 +0200 Subject: [PATCH] fix(apps): reply to the sandboxed SDK handshake over its own port Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz --- backend/windmill-api/src/apps.rs | 24 +++---- .../components/raw_apps/RawAppPreview.svelte | 68 +++++++++---------- typescript-client/client.ts | 9 ++- 3 files changed, 49 insertions(+), 52 deletions(-) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index a272aa85e0..6831becb14 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -825,13 +825,9 @@ fn raw_app_wrapper_html(secret: &str) -> String { s.src = './__SECRET__.js'; document.body.appendChild(s); } - window.addEventListener('message', function (e) { - // Only our embedder may supply the context. It now carries the SDK's token - // and API base, so accepting it from any window would let another frame - // point the bundle's API calls at a host it controls. - if (e.source !== window.parent) return; - var d = e.data || {}; - if (d.type === 'windmill:ctx') { + function applyCtx(d) { + if (!d || d.type !== 'windmill:ctx') return; + { window.ctx = d.ctx; if (window.__wmStorageShim && d.storage) { window.__wmStorageShim.local.__hydrate(d.storage.local); @@ -849,13 +845,17 @@ fn raw_app_wrapper_html(secret: &str) -> String { } loadBundle(); } - }); - // 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. + } + // Announce readiness over a MessageChannel we own, echoing the nonce from our + // own URL. The nonce proves to the embedder which document is asking (one + // navigated in later cannot read it); replying on our port guarantees the + // answer reaches only this document, since replacing it discards the port. + // Both are needed before the embedder parts with the viewer's SDK token. try { var hs = new URLSearchParams(window.location.search).get('wm_hs') || undefined; - window.parent.postMessage({ type: 'windmill:ready', nonce: hs }, '*'); + var ch = new MessageChannel(); + ch.port1.onmessage = function (e) { applyCtx(e.data); }; + window.parent.postMessage({ type: 'windmill:ready', nonce: hs }, '*', [ch.port2]); } catch (_) {} // Fallback for contexts that never send ctx (e.g. ctx-less rendering). setTimeout(loadBundle, 1500); diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 0ba53f5433..bd88c0c4e9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -103,12 +103,8 @@ new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated ? 'wm_coep=1&' : '' - // 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. + // wm_hs: the handshake nonce, readable only by the document we load here — + // see `respondCtx` for what it gates. return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html?${coep}wm_hs=${handshakeNonce}` }) @@ -143,8 +139,11 @@ 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). + // The `windmill:ready` we could not answer yet (storage still loading). Holding + // the port is what makes the deferred reply safe: it stays bound to the document + // that sent it even if the frame is navigated while we wait. let pendingNonce: string | undefined = undefined + let pendingPort: MessagePort | undefined = undefined function readDirect(): Record { try { @@ -164,30 +163,27 @@ } catch (_) {} } - /** 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', - // Same shape as the unsandboxed wrapper: always the object, so - // `window.ctx.workspace` works for anonymous viewers too. - ctx: { ctx: user, workspace }, - initialHash, - storage: { local: bundleStorage ?? {}, session: {} }, - // The wrapper turns this into `window.process.env` before it injects - // the bundle, which is what a bundled `windmill-client` reads at - // module load. `baseUrl` must come from here: this component runs on - // the real origin, the bundle's is opaque. - ...(sdkToken - ? { sdk: { token: sdkToken, baseUrl: window.location.origin, workspace } } - : {}) - }, - '*' - ) + /** The credential is gated on two things: the nonce proves which document is + * asking (one navigated in later cannot read it from our URL), and replying on + * the port that document sent proves the answer reaches only it — posting to + * `contentWindow` would land in whatever document is active by then. */ + function respondCtx(nonceEcho?: string, port?: MessagePort) { + const proven = nonceEcho === handshakeNonce && port !== undefined + const sdkToken = proven ? sdkTokenCtx?.value : undefined + const payload = { + type: 'windmill:ctx', + // Same shape as the unsandboxed wrapper: always the object, so + // `window.ctx.workspace` works for anonymous viewers too. + ctx: { ctx: user, workspace }, + initialHash, + storage: { local: bundleStorage ?? {}, session: {} }, + // The wrapper turns this into `window.process.env` before it injects the + // bundle, which is what a bundled `windmill-client` reads at module load. + // `baseUrl` comes from here because the bundle's own origin is opaque. + ...(sdkToken ? { sdk: { token: sdkToken, baseUrl: window.location.origin, workspace } } : {}) + } + if (port) port.postMessage(payload) + else iframe?.contentWindow?.postMessage(payload, '*') } onMount(() => { @@ -206,7 +202,7 @@ bundleStorage = {} if (pendingReady) { pendingReady = false - respondCtx(pendingNonce) + respondCtx(pendingNonce, pendingPort) } } }, 750) @@ -222,7 +218,7 @@ bundleStorage = data.data || {} if (pendingReady) { pendingReady = false - respondCtx(pendingNonce) + respondCtx(pendingNonce, pendingPort) } return } @@ -231,14 +227,16 @@ if (data?.type === 'windmill:ready') { // Hand the bundle its context + shared storage before it evaluates. const nonceEcho = typeof data.nonce === 'string' ? data.nonce : undefined + const port = event.ports?.[0] if (!framed) { bundleStorage = readDirect() - respondCtx(nonceEcho) + respondCtx(nonceEcho, port) } else if (bundleStorage !== undefined) { - respondCtx(nonceEcho) + respondCtx(nonceEcho, port) } else { pendingReady = true pendingNonce = nonceEcho + pendingPort = port } } else if (data?.type === 'wm_ls_op') { // The bundle mutated localStorage — apply it to the shared store. diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 178ade222f..bca21f2340 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -68,11 +68,10 @@ export function setClient(token?: string, baseUrl?: string) { if (token === undefined) { token = getEnv("WM_TOKEN") ?? "no_token"; } - // Credentials must be off whenever a bearer token is in play: the API answers - // `Access-Control-Allow-Origin: *` and so can never allow them, and a - // credentialed cross-origin request is rejected before the token is looked at. - // (`"no_token"` is still sent as a bearer, so this branch is about credentials - // mode only — it does not make cookie auth work.) + // Credentials must be off whenever a bearer is sent: the API answers + // `Access-Control-Allow-Origin: *`, so a credentialed cross-origin request is + // rejected before the token is read. Credentials mode only — `"no_token"` is + // still sent as a bearer, so this does not enable cookie auth. OpenAPI.WITH_CREDENTIALS = token === "no_token"; OpenAPI.TOKEN = token; OpenAPI.BASE = baseUrl + "/api";