diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index 40cc2a4416..68ed636640 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -593,6 +593,7 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; // Create raw_app.yaml with data configuration const rawAppConfig: Record = { summary, + sandbox: true, }; // Add data configuration if present diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7feaec6983..6b3c2822ae 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6920,7 +6920,9 @@ Either one lets those people run the app's backend runnables, so never open an a ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In \`raw_app.yaml\`: \`\`\`yaml diff --git a/frontend/src/lib/components/OnBehalfOfSelector.svelte b/frontend/src/lib/components/OnBehalfOfSelector.svelte index 67648936ae..99ce21714e 100644 --- a/frontend/src/lib/components/OnBehalfOfSelector.svelte +++ b/frontend/src/lib/components/OnBehalfOfSelector.svelte @@ -26,7 +26,7 @@ - e.detail && loadUsers()} -> - {#snippet trigger()} - - - {#if selectedDisplayName} - {selectedDisplayName} - {/if} - {#if isFolderDefaultSelected} - + + {myDisplayName} + +{:else} + e.detail && loadUsers()} + > + {#snippet trigger()} + + + {#if selectedDisplayName} + {selectedDisplayName} + {/if} + {#if isFolderDefaultSelected} + + folder default + + {/if} + + {/snippet} + {#snippet content({ close: closePopover })} +
+
{label}
+ + {#if targetValue} + + {/if} + + + - {/if} - - - - -
- {/snippet} -
+ + {/snippet} +
+{/if} - { - e.stopPropagation() - }, - onkeyup: () => { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - } - }} - bind:value={summary} - /> - -
- -
- -
- - -
-
- - +
+ {#if !onLatest} + + {headUnknown + ? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff." + : "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."} + + {/if} -
- - A viewer of the app will execute the runnables of the app on behalf of the publisher (you) - - It ensures that all required resources/runnable visible for publisher but not for viewer at time - of creating the app would prevent the execution of the app. To guarantee tight security, a - policy is computed at time of deployment of the app which only allow the scripts/flows referred - to in the app to be called on behalf of. Furthermore, static parameters are not overridable. - Hence, users will only be able to use the app as intended by the publisher without risk for - leaking resources not used in the app. - - {#if canPreserve} -
- Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another - user to run this app on behalf of. Once deployed the app will be run on behalf of - { - onBehalfOfChoice = choice - if (choice === 'me') { - policy.on_behalf_of_email = $userStore?.email - policy.on_behalf_of = `u/${$userStore?.username}` - customOnBehalfOfEmail = '' - preserveOnBehalfOf = false - } else if (choice === 'target') { - policy.on_behalf_of_email = savedOnBehalfOfEmail - policy.on_behalf_of = savedOnBehalfOf - customOnBehalfOfEmail = '' - preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - policy.on_behalf_of_email = details.email - policy.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - preserveOnBehalfOf = true +
+
+ + + { + e.stopPropagation() + }, + onkeyup: () => { + if (appPath == '' && summary?.length > 0 && !dirtyPath) { + path?.setName( + summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } } }} - kind="app" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} + bind:value={summary} + /> + +
+
+ +
- {/if} - - -
- -
-

Sandbox isolation

- Alpha -
-
- { - policy.sandbox = e.detail || undefined - // Counted where the toggle is flipped rather than where the policy is - // persisted: a not-yet-deployed app only mutates it locally, and skipping - // those would read as unused in the case where it is picked up front. - logFeatureUsage('app_sandbox', 'toggled', { - key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` - }) - // Frontend API access exists only for a sandboxed app, so turning - // isolation off drops the declared scopes with it rather than leaving - // them set but inert. - if (!e.detail) { - policy.frontend_sdk_scopes = undefined - } - // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST - // /apps/update) would 404. The flag rides along in the `policy` the first - // deploy sends (createApp), so here we only mutate it locally. Persist - // incrementally once the app exists. - if (savedApp && !newApp) { - setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') - } - }} - disabled={!savedApp || $operatorBuilderApps} - /> - {#if $operatorBuilderApps} -
- Required for apps built by operators, and cannot be turned off. +
+ +
- {/if} -
- Controls what the app's browser-side code can reach in each viewer's browser — distinct from the - on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code - uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead, - on every surface (public URL and in-workspace). Leave it off if the app needs full browser - features (IndexedDB, third-party auth/SDKs, OAuth redirects).
- {#if newApp} -
Takes effect when you first deploy this app.
- {/if} - {#if policy.sandbox == true} -
- - Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm - it still works, and report any broken behavior. - -
- {/if} -
-{#if rawApp && policy.sandbox == true} -

Frontend API access

-
-
- Let the app's frontend code call the Windmill API through the windmill-client - SDK, authenticated as the viewer (unlike runnables, which run on behalf of the - publisher). Each viewer is asked to approve the scopes below before the app runs. Grant only - what the app needs: its code — or an XSS bug in it — can use them as that viewer. Add - windmill-client to the app's dependencies to import it; it configures itself from - the token handed to the bundle. -
- {#each FRONTEND_SDK_SCOPES as scope (scope.value)} -
- { - const current: string[] = policy.frontend_sdk_scopes ?? [] - const next = e.detail - ? [...current, scope.value] - : current.filter((s) => s !== scope.value) - // Keep the curated order so the consent banner and the stored - // consent compare stably across deploys. - const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s)) - policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined - // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the - // scopes ride along in the first deploy's policy instead. - if (savedApp && !newApp) { - setPublishState('Frontend API access updated') +
+
+
+ { + onBehalfOfChoice = choice + if (choice === 'me') { + policy.on_behalf_of_email = $userStore?.email + policy.on_behalf_of = `u/${$userStore?.username}` + customOnBehalfOfEmail = '' + preserveOnBehalfOf = false + } else if (choice === 'target') { + policy.on_behalf_of_email = savedOnBehalfOfEmail + policy.on_behalf_of = savedOnBehalfOf + customOnBehalfOfEmail = '' + preserveOnBehalfOf = true + } else if (choice === 'custom' && details) { + policy.on_behalf_of_email = details.email + policy.on_behalf_of = details.permissionedAs + customOnBehalfOfEmail = details.email + preserveOnBehalfOf = true } }} - disabled={!savedApp} + kind="app" + {canPreserve} + customValue={customOnBehalfOfEmail} + isDeployment={false} + myPermissionedAs={myOnBehalfOf} + readOnlyWithoutPreserve /> -
{scope.description}
+ {#if onBehalfOfWillChange} + + will change from {savedOnBehalfOf} on deploy + {/if}
- {/each} - {#if newApp} -
Takes effect when you first deploy this app.
- {/if} - {#if policy.frontend_sdk_scopes?.length} -
- - A sandboxed app calls the API cross-origin, which older windmill-client 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. - -
- {/if} -
-{/if} - -{#if !hideSecretUrl} -

Access

- -
- {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} - - Opening this app to anyone with the link is restricted to workspace admins and bypass users - by a workspace protection rule - -
- {/if} - {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} - - Opening this app to guests is restricted to workspace admins and bypass users by a workspace - protection rule - -
- {/if} -
- onAccessModeChange(e.detail)} - disabled={!savedApp} - > - {#snippet children({ item })} - - - - {/snippet} - -
-
- {#if policy.execution_mode == 'anonymous'} - Anyone holding the secret URL below can open this app without signing in. - {:else if policy.execution_mode == 'guest'} - {#if !guestsAvailable} - Guests are not available on Windmill Cloud, so this app still admits members only. They - require a self-hosted instance or a dedicated Windmill Cloud deployment. - {:else if guestUsage && !guestUsage.instance_enabled} - A superadmin has turned guests off for this instance, so this app still admits members - only. - {:else if guestAccessEnabled === undefined} - Checking whether this workspace allows guests… - {:else if guestAccessEnabled === false} - Guests are turned off for this workspace, so this app still admits members only. A - workspace admin can turn them on in the workspace settings. +
+ {#if canPreserve} + Viewers run the app's runnables as this user. {:else} - Anyone your identity provider authenticates can open this app without a Windmill account. - They join no workspace. Members of this workspace can open it too. - {#if guestUsage} - {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance - in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered - ? 'every four guests count as one seat' - : 'new guests are refused until the count drops'}. - {/if} + Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user. {/if} - {:else} - Only workspace members with read access on this app can open it. - {/if} -
- {#if !savedApp || newApp} - - {:else if secretUrlHref} -
- (embedMode = e.detail)} - options={{ left: 'URL', right: 'Embed' }} - />
- - {:else} - {/if} -
- {#if embedMode} - Paste this iframe snippet into another app. - {#if rawApp} - To embed it in a cross-origin isolated page, add wm_coep=on - Sets the cross-origin isolation headers (COEP) on the app, which such a page requires - of its iframes. The app then only loads cross-origin resources that send CORS or a - Cross-Origin-Resource-Policy header. to the URL. - {/if} - (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) - {:else} - Share this url directly, or switch to Embed to get an iframe snippet. - {/if}
+
- {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} -
-
- Embed for your own authenticated users (guest JWT) -
-
- To open this app for a user your own product already authenticates, mint a short-lived JWT - in your backend and append it to the app URL as guest.<jwt>. Each token - is its own seatless guest, confined to this app — no shared secret and no Windmill - account, unlike the plain secret URL above. -
-
- Windmill verifies the token against the workspace's guest JWT key (Workspace settings → - Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's - configured issuer (JWT_EXT_JWKS_URL) when no workspace key is set{/if}. Set - the public half there; in your backend, sign each token with the matching - private key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is - refused), carrying email, workspace_id = {opWs}, - app_path = {appPath} and exp (at most 24h ahead). -
- -
- Replace YOUR_GUEST_JWT with the token your backend signs per user. Past the instance's - free guest allowance a new guest email is refused (see the count above); guests already seen - in the window keep working. -
+
+ { + policy.sandbox = e.detail || undefined + // Counted where the toggle is flipped rather than where the policy is + // persisted: a not-yet-deployed app only mutates it locally, and skipping + // those would read as unused in the case where it is picked up front. + logFeatureUsage('app_sandbox', 'toggled', { + key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` + }) + // Frontend API access exists only for a sandboxed app, so turning + // isolation off drops the declared scopes with it rather than leaving + // them set but inert. + if (!e.detail) { + policy.frontend_sdk_scopes = undefined + } + // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST + // /apps/update) would 404. The flag rides along in the `policy` the first + // deploy sends (createApp), so here we only mutate it locally. Persist + // incrementally once the app exists. + if (savedApp && !newApp) { + setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') + } + }} + disabled={!savedApp || $operatorBuilderApps} + /> + {#if $operatorBuilderApps || newApp} +
+ {#if $operatorBuilderApps} + Required for apps built by operators, and cannot be turned off. + {/if} + {#if newApp} + Takes effect when you first deploy this app. + {/if}
{/if} - -
- {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} - - Custom path can only be set by workspace admins + {#if !$operatorBuilderApps && policy.sandbox != true} +
+ + The app's frontend code runs with each viewer's full Windmill permissions. A future + version will ask viewers to approve this. Keep isolation off only if the app needs it. -
- {/if} - - {#if !$enterpriseLicense} - - {/if} - { - customPath = detail ? '' : undefined - if (customPath === undefined) { - customPathError = '' - } - }} - checked={customPath !== undefined} - options={{ - right: 'Use a custom URL' - }} - disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} - /> +
+ {/if} - {#if customPath !== undefined} -
-
Custom path
-
- { - dirtyCustomPath = true - }} - /> -
-
Custom public URL
-
- + {#if rawApp && policy.sandbox == true} +
+
+ {#snippet badge()} + {#if policy.frontend_sdk_scopes?.length} +
+ {#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)} + {scope.label} + {/each} +
+ {/if} + {/snippet} +
+ Let the app's frontend call the Windmill API as the viewer through the + windmill-client SDK, which it lists as a dependency. Grant only what the app + needs. +
+
+ {#each FRONTEND_SDK_SCOPES as scope (scope.value)} +
+ { + const current: string[] = policy.frontend_sdk_scopes ?? [] + const next = e.detail + ? [...current, scope.value] + : current.filter((s) => s !== scope.value) + // Keep the curated order so the consent banner and the stored + // consent compare stably across deploys. + const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => + next.includes(s) + ) + policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined + // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the + // scopes ride along in the first deploy's policy instead. + if (savedApp && !newApp) { + setPublishState('Frontend API access updated') + } + }} + disabled={!savedApp} + /> +
{scope.description}
+
+ {/each} +
+ {#if policy.frontend_sdk_scopes?.length} +
+ + A sandboxed app calls the API cross-origin, which older windmill-client + 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. + +
+ {/if} +
+
+ {/if} +
-
{dirtyCustomPath ? customPathError : ''} + {#if !hideSecretUrl} +
+
+ {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} + + Opening this app to anyone with the link is restricted to workspace admins and bypass + users by a workspace protection rule + + {/if} + {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} + + Opening this app to guests is restricted to workspace admins and bypass users by a + workspace protection rule + + {/if} +
+ onAccessModeChange(e.detail)} + disabled={!savedApp} + > + {#snippet children({ item })} + + + + {/snippet} + +
+
+ {#if policy.execution_mode == 'anonymous'} + Anyone holding the secret URL below can open this app without signing in. + {:else if policy.execution_mode == 'guest'} + {#if !guestsAvailable} + Guests are not available on Windmill Cloud, so this app still admits members only. + They require a self-hosted instance or a dedicated Windmill Cloud deployment. + {:else if guestUsage && !guestUsage.instance_enabled} + A superadmin has turned guests off for this instance, so this app still admits members + only. + {:else if guestAccessEnabled === undefined} + Checking whether this workspace allows guests… + {:else if guestAccessEnabled === false} + Guests are turned off for this workspace, so this app still admits members only. A + workspace admin can turn them on in the workspace settings. + {:else} + Anyone your identity provider authenticates can open this app without a Windmill + account. They join no workspace. Members of this workspace can open it too. + {#if guestUsage} + {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this + instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered + ? 'every four guests count as one seat' + : 'new guests are refused until the count drops'}. + {/if} + {/if} + {:else} + Only workspace members with read access on this app can open it. + {/if} +
+
+ +
+ {#if !savedApp || newApp} + + {:else if secretUrlHref} +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ + {:else} + {/if} +
+ {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + To embed it in a cross-origin isolated page, add wm_coep=on + Sets the cross-origin isolation headers (COEP) on the app, which such a page + requires of its iframes. The app then only loads cross-origin resources that send + CORS or a Cross-Origin-Resource-Policy header. to the URL. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this URL directly, or switch to Embed to get an iframe snippet. Only the latest + deployed version is served. + {/if} +
+
+ + {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} +
+
+ Embed for your own authenticated users (guest JWT) +
+
+ To open this app for a user your own product already authenticates, mint a short-lived + JWT in your backend and append it to the app URL as guest.<jwt>. Each + token is its own seatless guest, confined to this app. Unlike the plain secret URL + above, there is no shared secret and no Windmill account. +
+
+ Windmill verifies the token against the workspace's guest JWT key (Workspace settings → + Guests): a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's + configured issuer (JWT_EXT_JWKS_URL) when no workspace key is set{/if}. + Set the public half there; in your backend, sign each token with the matching + private key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is + refused), carrying email, workspace_id = {opWs}, + app_path = {appPath} and exp (at most 24h ahead). +
+ +
+ Replace YOUR_GUEST_JWT with the token your backend signs per user. Past the + instance's free guest allowance a new guest email is refused (see the count above); guests + already seen in the window keep working. +
{/if} -
-
- - You will still need to deploy the app to make visible the latest changes - - Embed this app in your own product to be used by your own users -{/if} +
+ {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} + + Custom path can only be set by workspace admins + + {/if} +
+ { + customPath = detail ? '' : undefined + if (customPath === undefined) { + customPathError = '' + } + }} + checked={customPath !== undefined} + options={{ + right: 'Use a custom URL' + }} + disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} + /> + {#if !$enterpriseLicense} + + {/if} +
+ + {#if customPath !== undefined} +
+ + { + dirtyCustomPath = true + } + }} + bind:value={customPath} + error={dirtyCustomPath ? customPathError : undefined} + /> + {#if dirtyCustomPath && customPathError} +
{customPathError}
+ {/if} +
+
+
Custom public URL
+ +
+ {/if} +
+ + Embed this app in your own product to be used by your own users + + {/if} +
diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index eacb3652e1..4c391c0336 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -194,21 +194,38 @@ // Top-level navigation relay (WIN-2006): in-app links to other apps (navbar // "app" items) must navigate the top page — navigating inside the opaque // iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn. - setContext( - EMBED_NAV_CONTEXT_KEY, - isViewer - ? { - navigateTop: (href: string) => { - try { - window.parent.postMessage( - { type: 'wm_embed_navigate', href }, - expectedEmbedderOrigin ?? '*' - ) - } catch (_) {} - } - } - : undefined - ) + function navigateTop(href: string) { + try { + window.parent.postMessage({ type: 'wm_embed_navigate', href }, expectedEmbedderOrigin ?? '*') + } catch (_) {} + } + setContext(EMBED_NAV_CONTEXT_KEY, isViewer ? { navigateTop } : undefined) + + // Plain links (markdown, HTML components) would otherwise load inside the + // cookieless frame, or put an external site in it. Bound on so the link's own + // handlers run first and can cancel it, and before SvelteKit's router on , + // which skips a prevented click. A link to this same page only changes ?query/#hash, + // which the hash relay mirrors, so it stays in the frame. + function relayLinkClick(e: MouseEvent) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) + return + // Attributes, not properties: on an SVG those are SVGAnimatedString. + const a = (e.target as Element | null)?.closest?.('a[href]') + const target = a?.getAttribute('target') + if ( + !a || + a.hasAttribute('download') || + (target && !['_self', '_top', '_parent'].includes(target)) + ) + return + const url = new URL(a.getAttribute('href')!, document.baseURI) + // The embedder only relays http(s); mailto:, tel: and the like work in the frame. + if (url.protocol !== 'http:' && url.protocol !== 'https:') return + const sameOrigin = url.origin === window.location.origin + if (sameOrigin && url.pathname === window.location.pathname) return + e.preventDefault() + navigateTop(sameOrigin ? url.pathname + url.search + url.hash : url.href) + } function installHashRelay() { origPushState = history.pushState origReplaceState = history.replaceState @@ -568,6 +585,7 @@ loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() + document.body.addEventListener('click', relayLinkClick) // Announce readiness so the embedder sends us the token. window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*') // This page is only ever loaded as the embedder's opaque iframe, which @@ -589,7 +607,10 @@ if (!BROWSER) return window.removeEventListener('message', handleViewerMessage) window.removeEventListener('message', handleEmbedderMessage) - if (isViewer) uninstallHashRelay() + if (isViewer) { + uninstallHashRelay() + document.body.removeEventListener('click', relayLinkClick) + } }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c1437cb21f..fc28f6cd5b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6970,6 +6970,7 @@ async function initApp( summary, files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] }, runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), + policy: { sandbox: true }, ...(data && { data: { tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)), diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index e41d0e5fd1..2006aef442 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -464,7 +464,7 @@ addEventListener('message', function (e) { n.hidden = !e.data.text } else if (e.source === opener) frame.postMessage(e.data, '*') }) -<\/script>` +<\/script>` ], { type: 'text/html' } ) @@ -2937,6 +2937,7 @@ addEventListener('message', function (e) { title="App preview" src={previewShellUrl} sandbox={sandboxed ? RAW_APP_SANDBOX_FLAGS : undefined} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={sandboxed ? 'no-referrer' : undefined} class="w-full flex-1 block" > diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index fa3e7e6211..36c3fb360c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -351,6 +351,7 @@ title="raw-app" src={iframeSrc} sandbox={sandboxAttr} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={unsandboxed ? undefined : 'no-referrer'} onload={() => (loadedSrc = iframeSrc)} style="display: block; width: 100%; height: 100%; border: none" diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index a052e54f97..e56ef4bff6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -298,7 +298,8 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } if ($operatorBuilderApps) conformBuilderAppPolicy(policy, $userStore) return policy diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 6d4b7f568d..f40882b95b 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1161,7 +1161,8 @@ function createRuntime(session: Session): SessionRuntime { policy: { on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, on_behalf_of_email: user?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true }, summary: '', path, diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 611d5b96e8..44dec7f124 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -551,7 +551,7 @@ export async function deployDraft( // undefined so the backend preserves the existing route. The draft has no // custom_path, so admins fall back to the deployed route (`''` when none). const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) - const policy = r.policy ?? { execution_mode: 'publisher' } + const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true } const requestBody = { value: appValue, summary: draftSummary ?? r.summary ?? '', diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 974d1c7f64..57e387ad20 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -108,7 +108,7 @@ itemKind: 'app', path }) - const emptyPolicy = { execution_mode: 'publisher' } as any + const emptyPolicy = { execution_mode: 'publisher', sandbox: true } as any // One-shot import handoff via $importStore (YAML/JSON or "Build app"). // Wrapped exports carry { summary, value, policy }; bare ones are the App value. const importRaw = $importStore @@ -246,7 +246,7 @@ summary: savedDraftApp?.summary ?? '', value: (savedDraftApp ?? {}) as App, path: page.params.path ?? '', - policy: { execution_mode: 'publisher' } as any, + policy: { execution_mode: 'publisher', sandbox: true } as any, custom_path: undefined, versions: undefined as any, id: 0 as any, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index ef1265c37a..8956f42441 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -235,7 +235,8 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } as any // Explicit path seed: the fork-a-draft handoff re-homes the source // path into the forker's namespace and passes it here. @@ -385,7 +386,7 @@ data: savedRawAppDraft?.data } backendApp.summary = savedRawAppDraft?.summary ?? '' - backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher' } + backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher', sandbox: true } backendApp.custom_path = savedRawAppDraft?.custom_path backendApp.path = page.params.path ?? '' } else if (savedRawAppDraft) { diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 1e66094664..507c3ed474 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1144,7 +1144,10 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. +You cannot turn isolation off; the user does it in the app's deploy settings. ## Reading data as the viewer diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index f4dce90269..630d650ed2 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -477,7 +477,9 @@ Either one lets those people run the app's backend runnables, so never open an a ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In `raw_app.yaml`: ```yaml diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index efe05f2c93..4350de4085 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -243,7 +243,12 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. + +You cannot turn isolation off; the user does it in the app's deploy settings. + In `raw_app.yaml`: