diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4ff1c5c69d..87b4fe039d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -830,6 +830,13 @@ fn raw_app_wrapper_html(secret: &str) -> String { window.__wmStorageShim.local.__hydrate(d.storage.local); window.__wmStorageShim.session.__hydrate(d.storage.session); } + // Frontend SDK: a bundled `windmill-client` reads window.process.env at + // module load, so the env must exist before the bundle script below is + // injected. BASE_URL is supplied by the embedder rather than derived — + // location.origin is "null" on this opaque origin. + if (d.sdk && d.sdk.token) { + window.process = { env: { WM_TOKEN: d.sdk.token, BASE_URL: d.sdk.baseUrl, WM_WORKSPACE: d.sdk.workspace } }; + } if (d.initialHash && d.initialHash !== '#' && !window.location.hash) { try { history.replaceState(null, '', d.initialHash); } catch (_) {} } @@ -1322,11 +1329,13 @@ async fn mint_raw_app_sdk_token( /// apps whose policy declares `frontend_sdk_scopes` get the viewer-scoped SDK /// token once the viewer confirms the permission prompt (`sdk_consent`). /// -/// `sdk_consent` is the viewer's answer to that prompt, not a security boundary: -/// an unsandboxed raw app's bundle runs same-origin with the viewer's session and -/// can call this endpoint itself. The boundary is the scope set — the token can -/// never exceed the policy-declared scopes, which are also capped by the viewer's -/// own permissions. Sandbox isolation is what actually contains an app's code. +/// For an unsandboxed app `sdk_consent` is the viewer's answer to that prompt, +/// not a security boundary: its bundle runs same-origin with the viewer's session +/// and can call this endpoint itself. The boundary is the scope set — the token +/// can never exceed the policy-declared scopes, which are also capped by the +/// viewer's own permissions. Sandbox isolation is what contains an app's code; a +/// sandboxed app holds nothing but this token, so there the prompt is the only +/// way it gains any reach at all. /// /// The CALLER MUST verify that `opt_authed` may view `app_path` before calling: /// this mints on their behalf unconditionally and does no visibility check of its @@ -1342,15 +1351,9 @@ pub async fn build_embed_token_response( sdk_consent: bool, ) -> Result { // Only advertise scopes where a token could actually be minted, so the viewer - // never shows a permission prompt that can grant nothing: a sandboxed bundle - // lives on an opaque origin and can't use the token, and an anonymous visitor - // has no identity to mint against. The backend, not the viewer, decides this - // (the editor shows the author the same sandbox rule). - let sdk_scopes = if raw_app - && !policy.sandbox - && opt_authed.is_some() - && !policy.frontend_sdk_scopes.is_empty() - { + // is never shown a permission prompt that can grant nothing: an anonymous + // visitor has no identity to mint against. + let sdk_scopes = if raw_app && opt_authed.is_some() && !policy.frontend_sdk_scopes.is_empty() { Some(policy.frontend_sdk_scopes.clone()) } else { None diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index f4d30cbd77..07054f5471 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -655,7 +655,14 @@ pub async fn run_server( .layer(Extension(argon2.clone())) .layer(cors.clone()), ) - .nest("/variables", variables::workspaced_service()) + // CORS so a sandboxed raw app's opaque-origin bundle can + // read variables with its frontend SDK token. Bearer-only: + // the layer never allows credentials, so no cookie can ride + // a cross-origin call. Consistent with resources/users. + .nest( + "/variables", + variables::workspaced_service().layer(cors.clone()), + ) .nest("/volumes", volumes_oss::workspaced_service()) .nest("/workers", windmill_api_workers::workspaced_service()) .nest("/workspaces", workspaces::workspaced_service()) diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index c17814ff11..2df3c83354 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -21,7 +21,10 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' - import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' + import { + FRONTEND_SDK_SCOPES, + MIN_SANDBOXED_SDK_VERSION + } from '$lib/components/raw_apps/sdkScopes' const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -370,9 +373,10 @@ {/if} {#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
- - A sandboxed app's bundle runs on an opaque origin and cannot use the SDK token. Turn off - sandbox isolation for these scopes to take effect. + + A sandboxed app calls the API cross-origin, which earlier versions of the SDK cannot do. + Make sure your app depends on windmill-client@^{MIN_SANDBOXED_SDK_VERSION} and + redeploy — apps bundled against an older version fail with a CORS error.
{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 6adc86336b..8aac3ca5de 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -151,6 +151,7 @@ } function respondCtx() { + const sdkToken = sdkTokenCtx?.value iframe?.contentWindow?.postMessage( { type: 'windmill:ctx', @@ -158,7 +159,14 @@ // `window.ctx.workspace` works for anonymous viewers too. ctx: { ctx: user, workspace }, initialHash, - storage: { local: bundleStorage ?? {}, session: {} } + 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 } } + : {}) }, '*' ) diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts index 9a825fc02b..e31b7e6515 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -3,6 +3,12 @@ // 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', diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 7e1c52335c..222605e7a8 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -22,12 +22,16 @@ const baseUrl = getEnv("BASE_INTERNAL_URL") ?? getEnv("BASE_URL") ?? "http://loc const baseUrlApi = (baseUrl ?? '') + "/api"; EOF +# WITH_CREDENTIALS mirrors setClient: cookies only when there is no bearer token. +# A browser bundle that never calls setClient (a raw app, which gets its token +# from window.process.env) must not send credentials — the API answers +# `Access-Control-Allow-Origin: *`, so a credentialed cross-origin request fails. if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts + sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts sed -i '' 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts sed -i '' "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts else - sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts + sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: !getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts fi diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 4a31d2dfed..d52421b3e8 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -68,7 +68,11 @@ export function setClient(token?: string, baseUrl?: string) { if (token === undefined) { token = getEnv("WM_TOKEN") ?? "no_token"; } - OpenAPI.WITH_CREDENTIALS = true; + // Cookies are the fallback for when there is no bearer token. Sending both is + // not just redundant, it breaks the browser case outright: the API answers + // `Access-Control-Allow-Origin: *` and so can never allow credentials, and a + // credentialed cross-origin request is rejected before the token is looked at. + OpenAPI.WITH_CREDENTIALS = token === "no_token"; OpenAPI.TOKEN = token; OpenAPI.BASE = baseUrl + "/api"; } @@ -79,8 +83,12 @@ function getPublicBaseUrl(): string { export const getEnv = (key: string) => { if (typeof window === "undefined") { - // node - return process?.env?.[key]; + // `process` may be undeclared entirely (web worker, browser-like runtimes + // without a node shim), where `process?.env` still throws a ReferenceError. + if (typeof process !== "undefined") { + return process?.env?.[key]; + } + return globalThis?.process?.env?.[key]; } // browser return window?.process?.env?.[key];