mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
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:
co-authored by
Claude Fable 5
parent
9069eccb60
commit
3dd117fff2
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user