feat: sandbox new low-code and raw apps by default (#11760)

* feat: sandbox new low-code and raw apps by default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: navigate the top page from plain links in a sandboxed low-code app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: allow clipboard and fullscreen in sandboxed raw-app frames

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: leave non-http links to the browser in the sandboxed app frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a link's own click handlers run before relaying it out of the frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: read link attributes so SVG anchors relay correctly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: tidy the app deploy drawer to the brand guidelines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: show enabled frontend api scopes as badges, rename runs-as section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep frontend api access collapsed by default, badges next to its title

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: lead the run-on-behalf-of section with the user picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: explain publisher execution and WM_END_USER_EMAIL in the run-on-behalf-of tooltip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: shorten the run-on-behalf-of tooltip, mention viewer data access for raw apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: tell the AI what breaks in a sandboxed raw app and that turning it off is a security risk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: warn in the deploy drawer when an app is not isolated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: drop the sandbox toggle help line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say when to turn sandbox isolation off in the toggle tooltip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: show who a non-deployer's app runs as, read-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show a new app runs as the deployer, gray read-only user line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: read-only on-behalf-of display as an opt-in selector prop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show a folder's default run-as user on a new app instead of the deployer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: deploy an app under a group folder default without a mismatched email

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* revert: leave a new app's run-as user unselected, as on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: warn in the app deploy drawer when a deploy changes the run-as user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: snapshot the deployed policy so later edits don't change the saved app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-15 12:54:00 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4f712318c3
commit 813c080d46
17 changed files with 586 additions and 507 deletions
+1
View File
@@ -593,6 +593,7 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName};
// Create raw_app.yaml with data configuration
const rawAppConfig: Record<string, unknown> = {
summary,
sandbox: true,
};
// Add data configuration if present
+3 -1
View File
@@ -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
@@ -26,7 +26,7 @@
</script>
<script lang="ts">
import { Check, UserCog, Users, ExternalLink } from 'lucide-svelte'
import { Check, User as UserIcon, UserCog, Users, ExternalLink } from 'lucide-svelte'
import MeltPopover from './meltComponents/Popover.svelte'
import Portal from './Portal.svelte'
import Modal from './common/modal/Modal.svelte'
@@ -74,6 +74,9 @@
* one is the wrong name whenever the deploy targets a different workspace.
*/
myPermissionedAs?: string
/** Without `canPreserve`, show the current user as plain text instead of a popover whose
* only enabled option is "me". The host must treat that as the choice already made. */
readOnlyWithoutPreserve?: boolean
}
let {
@@ -88,7 +91,8 @@
folderDefault = undefined,
aboveConfirmationModal = false,
onPickerOpenChange = undefined,
myPermissionedAs = undefined
myPermissionedAs = undefined,
readOnlyWithoutPreserve = false
}: Props = $props()
const isTrigger = $derived(kind === 'trigger' || isTriggerOrScheduleKind(kind))
@@ -187,77 +191,84 @@
})
</script>
<MeltPopover
placement="bottom"
contentClasses={aboveConfirmationModal ? 'z-[10001]' : ''}
on:openChange={(e) => e.detail && loadUsers()}
>
{#snippet trigger()}
<span class="inline-flex items-center gap-1">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
{#if selectedDisplayName}
<span class="text-xs truncate max-w-24">{selectedDisplayName}</span>
{/if}
{#if isFolderDefaultSelected}
<span
class="text-[10px] px-1 py-0.5 rounded bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-900"
title="From this folder's default_permissioned_as rule"
{#if readOnlyWithoutPreserve && !canPreserve}
<span class="inline-flex items-center gap-1 text-xs text-secondary">
<UserIcon class="w-4 h-4" />
{myDisplayName}
</span>
{:else}
<MeltPopover
placement="bottom"
contentClasses={aboveConfirmationModal ? 'z-[10001]' : ''}
on:openChange={(e) => e.detail && loadUsers()}
>
{#snippet trigger()}
<span class="inline-flex items-center gap-1">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
{#if selectedDisplayName}
<span class="text-xs truncate max-w-24">{selectedDisplayName}</span>
{/if}
{#if isFolderDefaultSelected}
<span
class="text-[10px] px-1 py-0.5 rounded bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-900"
title="From this folder's default_permissioned_as rule"
>
folder default
</span>
{/if}
</span>
{/snippet}
{#snippet content({ close: closePopover })}
<div class="p-3 flex flex-col gap-2 min-w-48">
<div class="text-xs font-medium text-secondary mb-1">{label}</div>
<!-- Target option -->
{#if targetValue}
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('target')}
>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{targetDisplayName}</span>
<span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span>
</button>
{/if}
<!-- Me option -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover"
onclick={() => onSelect('me')}
>
folder default
</span>
{/if}
</span>
{/snippet}
{#snippet content({ close: closePopover })}
<div class="p-3 flex flex-col gap-2 min-w-48">
<div class="text-xs font-medium text-secondary mb-1">{label}</div>
<!-- Target option -->
{#if targetValue}
<Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{myDisplayName}</span>
<span class="text-xs text-tertiary">(me)</span>
</button>
<!-- Custom / Pick from workspace -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('target')}
onclick={() => {
closePopover()
openModal()
}}
>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{targetDisplayName}</span>
<span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span>
{#if selected === 'custom' && customDisplayName}
<Check class="w-3 h-3 opacity-100" />
<span class="truncate max-w-40">{customDisplayName}</span>
<span class="text-xs text-tertiary">(custom)</span>
{:else}
<Check class="w-3 h-3 opacity-0" />
<Users class="w-3 h-3 text-tertiary" />
<span>Pick from workspace&hellip;</span>
{/if}
</button>
{/if}
<!-- Me option -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover"
onclick={() => onSelect('me')}
>
<Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{myDisplayName}</span>
<span class="text-xs text-tertiary">(me)</span>
</button>
<!-- Custom / Pick from workspace -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => {
closePopover()
openModal()
}}
>
{#if selected === 'custom' && customDisplayName}
<Check class="w-3 h-3 opacity-100" />
<span class="truncate max-w-40">{customDisplayName}</span>
<span class="text-xs text-tertiary">(custom)</span>
{:else}
<Check class="w-3 h-3 opacity-0" />
<Users class="w-3 h-3 text-tertiary" />
<span>Pick from workspace&hellip;</span>
{/if}
</button>
</div>
{/snippet}
</MeltPopover>
</div>
{/snippet}
</MeltPopover>
{/if}
<!-- User selection modal. Portalled: the modal positions itself with `fixed`, which resolves
against the nearest transformed ancestor — inside a dialog card (which is transformed for
@@ -307,7 +307,7 @@
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: path,
policy: policy,
policy: $state.snapshot(policy),
custom_path: customPath,
labels: $state.snapshot(labels)
}
@@ -425,7 +425,7 @@
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: npath,
policy,
policy: $state.snapshot(policy),
custom_path: customPath,
labels: $state.snapshot(labels)
}
@@ -1,10 +1,10 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { Alert, Section } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import Badge from '$lib/components/Badge.svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { Loader2 } from 'lucide-svelte'
import { AlertTriangle, Loader2 } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -158,6 +158,17 @@
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
let customOnBehalfOfEmail: string = $state('')
let myOnBehalfOf = $derived(actingUser?.username ? `u/${actingUser.username}` : undefined)
// Without the right to preserve, the backend runs the redeployed app as the deployer whatever
// the policy says, as it does for "me".
let deployOnBehalfOf = $derived.by(() => {
if (!canPreserve || onBehalfOfChoice === 'me') return myOnBehalfOf
if (onBehalfOfChoice === 'custom') return policy.on_behalf_of
return savedOnBehalfOf
})
let onBehalfOfWillChange = $derived(
!newApp && !!savedOnBehalfOf && !!deployOnBehalfOf && deployOnBehalfOf !== savedOnBehalfOf
)
let dirtyCustomPath = $state(false)
let path: Path | undefined = $state(undefined)
@@ -242,437 +253,454 @@
})
</script>
{#if !onLatest}
<Alert
title={headUnknown
? 'Could not check the deployed version. '
: "You're not on the latest app version. "}
type="warning"
>
{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."}
</Alert>
<div class="py-2"></div>
{/if}
<label for="summary" class="text-emphasis text-xs font-semibold">Summary</label>
<div class="w-full pt-1">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
id: 'summary',
autofocus: true,
placeholder: 'App summary',
onkeydown: (e) => {
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}
/>
</div>
<div class="pt-3"></div>
<LabelsInput bind:labels class="-mt-4" />
<div class="py-6"></div>
<label for="deploymentMsg" class="text-emphasis text-xs font-semibold">Deployment message</label>
<div class="w-full pt-1">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
id: 'deploymentMsg',
placeholder: 'Optional deployment message'
}}
bind:value={deploymentMsg}
/>
</div>
<div class="py-6"></div>
<label for="path" class="text-emphasis text-xs font-semibold">Path</label>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
workspaceOverride={operatingWorkspace}
/>
<div class="flex flex-col gap-8">
{#if !onLatest}
<Alert
title={headUnknown
? 'Could not check the deployed version. '
: "You're not on the latest app version. "}
type="warning"
>
{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."}
</Alert>
{/if}
<div class="py-2"></div>
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
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.
</Tooltip>
{#if canPreserve}
<div class="mt-4">
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
<OnBehalfOfSelector
targetWorkspace={opWs ?? ''}
targetValue={savedOnBehalfOfEmail}
selected={onBehalfOfChoice}
onSelect={(choice, details) => {
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
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-1">
<label for="summary" class="text-emphasis text-xs font-semibold">Summary</label>
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
id: 'summary',
autofocus: true,
placeholder: 'App summary',
onkeydown: (e) => {
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}
/>
<LabelsInput bind:labels />
</div>
<div class="flex flex-col gap-1">
<label for="deploymentMsg" class="text-emphasis text-xs font-semibold">
Deployment message
</label>
<TextInput
inputProps={{
id: 'deploymentMsg',
placeholder: 'Optional deployment message'
}}
bind:value={deploymentMsg}
/>
</div>
{/if}
</Alert>
<div class="mt-10"></div>
<div class="flex items-center gap-2">
<h2>Sandbox isolation</h2>
<Badge color="yellow">Alpha</Badge>
</div>
<div class="my-6">
<Toggle
options={{ right: "Isolate the app from the viewer's browser session" }}
checked={$operatorBuilderApps || policy.sandbox == true}
on:change={(e) => {
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}
<div class="text-xs text-secondary mt-1">
Required for apps built by operators, and cannot be turned off.
<div class="flex flex-col gap-1">
<label for="path" class="text-emphasis text-xs font-semibold">Path</label>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
workspaceOverride={operatingWorkspace}
/>
</div>
{/if}
<div class="text-xs text-secondary mt-1">
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).
</div>
{#if newApp}
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
{/if}
{#if policy.sandbox == true}
<div class="mt-2">
<Alert type="warning" title="Alpha feature" size="xs">
Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm
it still works, and report any broken behavior.
</Alert>
</div>
{/if}
</div>
{#if rawApp && policy.sandbox == true}
<h2 class="text-xs font-semibold">Frontend API access</h2>
<div class="mb-6 mt-2">
<div class="text-xs text-secondary mb-3">
Let the app's frontend code call the Windmill API through the <code>windmill-client</code>
SDK, authenticated as <b>the viewer</b> (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
<code>windmill-client</code> to the app's dependencies to import it; it configures itself from
the token handed to the bundle.
</div>
{#each FRONTEND_SDK_SCOPES as scope (scope.value)}
<div class="mb-2">
<Toggle
size="xs"
options={{ right: scope.label }}
checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false}
on:change={(e) => {
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')
<Section
label="Run on behalf of"
tooltip={`The app's runnables run as this user whoever opens the app, so viewers need no access of their own. To keep that safe, the app can call only the runnables it was deployed with, and viewers cannot override their static inputs. Runnables still get the viewer's email in WM_END_USER_EMAIL${rawApp ? ', and inline ones can be set to read data tables and object storage as the viewer' : ''}.`}
>
<div class="flex flex-col items-start gap-1">
<div class="flex items-center gap-1.5">
<OnBehalfOfSelector
targetWorkspace={opWs ?? ''}
targetValue={savedOnBehalfOfEmail}
selected={onBehalfOfChoice}
onSelect={(choice, details) => {
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
/>
<div class="text-xs text-hint ml-9">{scope.description}</div>
{#if onBehalfOfWillChange}
<AlertTriangle class="w-3.5 h-3.5 text-yellow-500" />
<span class="text-2xs text-yellow-600 dark:text-yellow-400"
>will change from <strong>{savedOnBehalfOf}</strong> on deploy</span
>
{/if}
</div>
{/each}
{#if newApp}
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
{/if}
{#if policy.frontend_sdk_scopes?.length}
<div class="mt-2">
<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}
</div>
{/if}
{#if !hideSecretUrl}
<h2>Access</h2>
<div class="my-6">
{#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to anyone with the link is restricted to workspace admins and bypass users
by a workspace protection rule
</Alert>
<div class="mb-2"></div>
{/if}
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
protection rule
</Alert>
<div class="mb-2"></div>
{/if}
<div class="flex gap-2 items-center mb-2">
<ToggleButtonGroup
selected={accessMode}
on:selected={(e) => onAccessModeChange(e.detail)}
disabled={!savedApp}
>
{#snippet children({ item })}
<ToggleButton
label="Members"
value="publisher"
tooltip="Workspace members with read access on this app."
{item}
/>
<ToggleButton
label="Guests"
value="guest"
disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)}
tooltip={!guestsAvailable
? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.'
: "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."}
{item}
/>
<ToggleButton
label="Public"
value="anonymous"
disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'}
tooltip="Anyone with the secret URL. No login."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="text-xs text-secondary mb-3">
{#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.
<div class="text-2xs text-secondary">
{#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}
</div>
{#if !savedApp || newApp}
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<div class="flex justify-end mb-1">
<Toggle
size="xs"
checked={embedMode}
on:change={(e) => (embedMode = e.detail)}
options={{ left: 'URL', right: 'Embed' }}
/>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
size="md"
/>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
{#if embedMode}
Paste this iframe snippet into another app.
{#if rawApp}
To embed it in a cross-origin isolated page, add <code>wm_coep=on</code>
<Tooltip
>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.</Tooltip
> 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 <b>Embed</b> to get an iframe snippet.
{/if}
</div>
</Section>
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
<div class="text-xs font-semibold text-emphasis">
Embed for your own authenticated users (guest JWT)
</div>
<div class="text-xs text-secondary">
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 <code>guest.&lt;jwt&gt;</code>. Each token
is its own seatless guest, confined to this app — no shared secret and no Windmill
account, unlike the plain secret URL above.
</div>
<div class="text-xs text-secondary">
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 (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. Set
the <b>public</b> half there; in your backend, sign each token with the matching
<b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is
refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
</div>
<ClipboardPanel
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
size="md"
/>
<div class="text-2xs text-secondary">
Replace <code>YOUR_GUEST_JWT</code> 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.
</div>
<Section label="Sandbox isolation">
<Toggle
options={{
right: "Isolate the app from the viewer's browser session",
rightTooltip:
"Controls what the app's browser-side code can reach in each viewer's browser, separately from who its runnables run as. A sandboxed app gets a narrowly scoped token on every surface, public URL and in-workspace. On by default for new apps. Turn it off only if the app needs IndexedDB, third-party sign-in or OAuth redirects."
}}
checked={$operatorBuilderApps || policy.sandbox == true}
on:change={(e) => {
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}
<div class="text-2xs text-secondary mt-1">
{#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}
</div>
{/if}
<div class="mt-4">
{#if !(actingUser?.is_admin || actingUser?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
{#if !$operatorBuilderApps && policy.sandbox != true}
<div class="mt-3">
<Alert type="warning" title="Not isolated" size="xs">
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.
</Alert>
<div class="mb-2"></div>
{/if}
<!-- svelte-ignore block_empty -->
{#if !$enterpriseLicense}
<EEOnly />
{/if}
<Toggle
on:change={({ detail }) => {
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)}
/>
</div>
{/if}
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!(actingUser?.is_admin || actingUser?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
oninput={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
size="md"
/>
{#if rawApp && policy.sandbox == true}
<div class="mt-6">
<Section
label="Frontend API access"
small
collapsable
tooltip="Each viewer approves these scopes before the app runs. The app's code, or an XSS bug in it, can use them as that viewer."
>
{#snippet badge()}
{#if policy.frontend_sdk_scopes?.length}
<div class="flex grow min-w-0 flex-wrap gap-1 ps-2">
{#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)}
<Badge twBgColor="bg-surface-sunken" twTextColor="text-primary"
>{scope.label}</Badge
>
{/each}
</div>
{/if}
{/snippet}
<div class="text-xs text-secondary mb-3">
Let the app's frontend call the Windmill API as <b>the viewer</b> through the
<code>windmill-client</code> SDK, which it lists as a dependency. Grant only what the app
needs.
</div>
<div class="flex flex-col gap-2">
{#each FRONTEND_SDK_SCOPES as scope (scope.value)}
<div>
<Toggle
size="xs"
options={{ right: scope.label }}
checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false}
on:change={(e) => {
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}
/>
<div class="text-2xs text-hint ml-9">{scope.description}</div>
</div>
{/each}
</div>
{#if policy.frontend_sdk_scopes?.length}
<div class="mt-3">
<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}
</Section>
</div>
{/if}
</Section>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
{#if !hideSecretUrl}
<Section label="Access">
<div class="flex flex-col gap-2">
{#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to anyone with the link is restricted to workspace admins and bypass
users by a workspace protection rule
</Alert>
{/if}
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to guests is restricted to workspace admins and bypass users by a
workspace protection rule
</Alert>
{/if}
<div>
<ToggleButtonGroup
selected={accessMode}
on:selected={(e) => onAccessModeChange(e.detail)}
disabled={!savedApp}
>
{#snippet children({ item })}
<ToggleButton
label="Members"
value="publisher"
tooltip="Workspace members with read access on this app."
{item}
/>
<ToggleButton
label="Guests"
value="guest"
disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)}
tooltip={!guestsAvailable
? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.'
: "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."}
{item}
/>
<ToggleButton
label="Public"
value="anonymous"
disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'}
tooltip="Anyone with the secret URL. No login."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="text-xs text-secondary">
{#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}
</div>
</div>
<div class="flex flex-col gap-1 mt-6">
{#if !savedApp || newApp}
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<div class="flex justify-end">
<Toggle
size="xs"
checked={embedMode}
on:change={(e) => (embedMode = e.detail)}
options={{ left: 'URL', right: 'Embed' }}
/>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
size="md"
/>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-2xs text-secondary">
{#if embedMode}
Paste this iframe snippet into another app.
{#if rawApp}
To embed it in a cross-origin isolated page, add <code>wm_coep=on</code>
<Tooltip
>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.</Tooltip
> 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 <b>Embed</b> to get an iframe snippet. Only the latest
deployed version is served.
{/if}
</div>
</div>
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
<div class="mt-6 flex flex-col gap-2">
<div class="text-xs font-semibold text-emphasis">
Embed for your own authenticated users (guest JWT)
</div>
<div class="text-xs text-secondary">
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 <code>guest.&lt;jwt&gt;</code>. 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.
</div>
<div class="text-xs text-secondary">
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 (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}.
Set the <b>public</b> half there; in your backend, sign each token with the matching
<b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is
refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
</div>
<ClipboardPanel
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
size="md"
/>
<div class="text-2xs text-secondary">
Replace <code>YOUR_GUEST_JWT</code> 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.
</div>
</div>
{/if}
</div>
</div>
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
>
{/if}
<div class="flex flex-col gap-2 mt-6">
{#if !(actingUser?.is_admin || actingUser?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
{/if}
<div class="flex items-center gap-2">
<Toggle
on:change={({ detail }) => {
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}
<EEOnly />
{/if}
</div>
{#if customPath !== undefined}
<div class="flex flex-col gap-1">
<label for="customPath" class="text-emphasis text-xs font-semibold">Custom path</label>
<TextInput
inputProps={{
id: 'customPath',
autocomplete: 'off',
disabled: !(actingUser?.is_admin || actingUser?.is_super_admin),
oninput: () => {
dirtyCustomPath = true
}
}}
bind:value={customPath}
error={dirtyCustomPath ? customPathError : undefined}
/>
{#if dirtyCustomPath && customPathError}
<div class="text-2xs text-red-600 dark:text-red-400">{customPathError}</div>
{/if}
</div>
<div class="flex flex-col gap-1">
<div class="text-emphasis text-xs font-semibold">Custom public URL</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
size="md"
/>
</div>
{/if}
</div>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="block mt-6 text-2xs"
>Embed this app in your own product to be used by your own users</a
>
</Section>
{/if}
</div>
@@ -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<EmbedNav | undefined>(
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<EmbedNav | undefined>(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 <body> so the link's own
// handlers run first and can cancel it, and before SvelteKit's router on <html>,
// 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 <a> 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)
}
})
</script>
@@ -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)),
@@ -464,7 +464,7 @@ addEventListener('message', function (e) {
n.textContent = e.data.text || ''
n.hidden = !e.data.text
} else if (e.source === opener) frame.postMessage(e.data, '*')
})
})
<\/script></head><body><div id="n" hidden></div><iframe src="${window.location.origin}${sandboxedShellUrl}" sandbox="${RAW_APP_SANDBOX_FLAGS}" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`
],
{ type: 'text/html' }
@@ -2937,6 +2937,7 @@ addEventListener('message', function (e) {
bind:this={previewIframe}
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"
@@ -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"
@@ -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
@@ -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,
+1 -1
View File
@@ -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 ?? '',
@@ -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,
@@ -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) {
+4 -1
View File
@@ -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
@@ -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
+6 -1
View File
@@ -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.
<!-- chat-only -->
You cannot turn isolation off; the user does it in the app's deploy settings.
<!-- /chat-only -->
<!-- cli-only -->
In `raw_app.yaml`: