fix(apps): hand the sandboxed SDK token over only once per loaded document

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:16:46 +02:00
co-authored by Claude Fable 5
parent 025069c41e
commit 571bf7ebcf
4 changed files with 27 additions and 22 deletions
@@ -21,10 +21,7 @@
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import {
FRONTEND_SDK_SCOPES,
MIN_SANDBOXED_SDK_VERSION
} from '$lib/components/raw_apps/sdkScopes'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -373,10 +370,10 @@
{/if}
{#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
<div class="mt-2">
<Alert type="info" title="Requires windmill-client {MIN_SANDBOXED_SDK_VERSION}" size="xs">
A sandboxed app calls the API cross-origin, which earlier versions of the SDK cannot do.
Make sure your app depends on <code>windmill-client@^{MIN_SANDBOXED_SDK_VERSION}</code> and
redeploy apps bundled against an older version fail with a CORS error.
<Alert type="info" title="Redeploy to use the SDK from a sandboxed app" size="xs">
A sandboxed app calls the API cross-origin, which older <code>windmill-client</code> versions
cannot do. An app bundled before this Windmill version fails with a CORS error until you deploy
it again, which re-bundles it against a current client.
</Alert>
</div>
{/if}
@@ -241,7 +241,7 @@
})
// Read by RawAppPreview: the consented viewer-scoped SDK token to expose to
// the bundle (unsandboxed raw apps only).
// the bundle, sandboxed or not.
setContext('RAW_APP_SDK_TOKEN', {
get value() {
return sdkToken
@@ -268,8 +268,8 @@
appPath = resp.app_path ?? undefined
workspaceId = resp.workspace_id ?? undefined
// The backend only advertises scopes where a token could actually be
// minted (raw, unsandboxed, viewer authenticated), and mints one only on
// the follow-up request carrying the viewer's consent.
// minted (a raw app, viewer authenticated), and mints one only on the
// follow-up request carrying the viewer's consent.
sdkScopes = resp.sdk_scopes?.length ? resp.sdk_scopes : undefined
viewerEmail = resp.viewer_email ?? ''
sdkToken = undefined
@@ -57,10 +57,11 @@
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
// document (`CSP: sandbox` response header + the iframe sandbox attribute),
// so a malicious bundle can never reach the authenticated Windmill origin
// (no cookie, no window.parent, no token). Root-relative so it resolves
// against the real host even when this component itself runs inside an opaque
// viewer (where `location.origin` is "null"). Context is handed over via
// postMessage — never baked into the document, never a credential.
// (no cookie, no window.parent). Root-relative so it resolves against the
// real host even when this component itself runs inside an opaque viewer
// (where `location.origin` is "null"). Context — and, when the viewer
// approved SDK scopes, the viewer-scoped token — is handed over via
// postMessage, never baked into the document.
// - UNSANDBOXED (the default — publisher did not opt into isolation): a
// client-built blob: wrapper (same-origin with the SPA) loaded with `allow-same-origin`,
// so relative `fetch('/api/...')` and the session cookie work. The backend
@@ -150,8 +151,21 @@
} 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 = sdkTokenCtx?.value
const sdkToken = sdkHandedOff ? undefined : sdkTokenCtx?.value
if (sdkToken) sdkHandedOff = true
iframe?.contentWindow?.postMessage(
{
type: 'windmill:ctx',
@@ -3,12 +3,6 @@
// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side
// consent persistence for the permission banner.
/** A sandboxed app calls the API from an opaque origin, which needs the
* credential-free client shipped alongside this release: earlier versions force
* `credentials: 'include'`, which CORS rejects. Tracks the monorepo version
* because `windmill-client` is published from it in lockstep. */
export const MIN_SANDBOXED_SDK_VERSION = __pkg__.version
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
{
value: 'jobs:run',