fix(apps): reply to the sandboxed SDK handshake over its own port

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 18:12:14 +02:00
co-authored by Claude Fable 5
parent 9069eccb60
commit 3dd117fff2
3 changed files with 49 additions and 52 deletions
+12 -12
View File
@@ -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);
@@ -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<string, string> | 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<string, string> {
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.
+4 -5
View File
@@ -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";