From 7da234481333fcbdc348215589e2d3bfe5394b4a Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:21:56 +0200 Subject: [PATCH] fix: let raw app SDK tokens read the viewer's identity without a toggle (#11790) * feat: grant raw app SDK tokens identity access without a toggle * test: pin the implicit identity scope on raw app SDK tokens * refactor: let raw app SDK tokens reach whoami at the route check --- backend/windmill-api-auth/src/scopes.rs | 12 +++++-- backend/windmill-api/openapi.yaml | 3 +- backend/windmill-api/src/apps.rs | 13 +++++-- .../src/mcp/auto_generated_endpoints.rs | 8 +++-- cli/src/guidance/skills.gen.ts | 4 +-- .../apps/editor/AppEditorHeaderDeploy.svelte | 10 +++--- .../raw_apps/RawAppSdkConsent.svelte | 4 +-- .../lib/components/raw_apps/sdkScopes.test.ts | 36 +++++++++++-------- .../src/lib/components/raw_apps/sdkScopes.ts | 26 +++++++++----- frontend/src/lib/mcpEndpointTools.ts | 8 +++-- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/raw-app/SKILL.md | 4 +-- system_prompts/base/raw-app.md | 4 +-- 13 files changed, 88 insertions(+), 46 deletions(-) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 1b605e70eb..bdbab1bfe7 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -73,7 +73,12 @@ pub fn check_route_access( .to_string(), )); } - if required_domain == ScopeDomain::Users && suffix != "users/whoami" { + if required_domain == ScopeDomain::Users { + // Whoami returns what the bundle already has in `window.ctx`, so it + // needs no declared scope. + if suffix == "users/whoami" { + return Ok(()); + } return Err(Error::PermissionDenied( "Access denied. A raw app frontend SDK token can only read the viewer's own identity." .to_string(), @@ -418,8 +423,9 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result) -> bool { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index db7280dbbf..e9b5732f0a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -36871,7 +36871,8 @@ components: the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, - flow_conversations:write). + flow_conversations:write). Every such token can call whoami without + consent, whether `users:read` is declared or not. ListableApp: type: object diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 1dcee82e5c..453422403d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -6779,8 +6779,8 @@ mod embed_token_tests { "POST", ), ("/api/w/test/jobs/restart/f/some-uuid", "POST"), - // `users:read` is presented to the viewer as "read your identity", so - // the workspace member directory must stay out of reach. + // `users:read` reaches whoami only: the workspace member directory must + // stay out of reach. ("/api/w/test/users/list", "GET"), ("/api/w/test/users/list_usage", "GET"), ("/api/w/test/users/username_to_email/admin", "GET"), @@ -6791,6 +6791,15 @@ mod embed_token_tests { "SDK token should deny {method} {path}" ); } + + // Whoami needs no declared scope. + let jobs_only = [ + "jobs:run".to_string(), + windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string(), + ]; + assert!( + check_scopes_for_route(Some(&jobs_only[..]), "/api/w/test/users/whoami", "GET").is_ok() + ); } /// The token carries path-scoped `apps:run:` and `apps:read:` diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index f449902f97..dd3c83a08d 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -599,6 +599,10 @@ pub fn all_tools() -> Vec { "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1336,7 +1340,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1453,7 +1457,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8d33007984..3b7bfa2298 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6926,14 +6926,14 @@ An app's custom URL is not in \`raw_app.yaml\`: each workspace has its own file ## Sandbox isolation and frontend API access -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. +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\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them. 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 sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] \`\`\` A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise. diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index dd6c163edd..41e0c5c3f2 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -24,7 +24,7 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' - import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' + import { FRONTEND_SDK_SCOPES, sdkScopesToConfirm } from '$lib/components/raw_apps/sdkScopes' import { logFeatureUsage } from '$lib/utils/featureUsage' import { useOperatingUser, @@ -436,7 +436,7 @@ 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} + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
{#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)} s !== scope.value) // Keep the curated order so the consent banner and the stored - // consent compare stably across deploys. + // consent compare stably across deploys. A leftover implicit scope is + // not in the list, so it drops here rather than keep minting a token + // for an app whose toggles all read off. const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s) ) @@ -481,7 +483,7 @@
{/each} - {#if policy.frontend_sdk_scopes?.length} + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
A sandboxed app calls the API cross-origin, which older windmill-client diff --git a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte index 28940eb02a..babe0b8c85 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' import { ShieldCheck } from 'lucide-svelte' - import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes' + import { sdkScopeDescription, sdkScopeLabel, sdkScopesToConfirm } from './sdkScopes' let { scopes, @@ -35,7 +35,7 @@ The app's code will be able to call the Windmill API on your behalf, restricted to:

    - {#each scopes as scope (scope)} + {#each sdkScopesToConfirm(scopes) as scope (scope)}
  • {sdkScopeLabel(scope)}{#if sdkScopeDescription(scope)} { beforeEach(() => localStorage.clear()) it('only covers scopes the viewer actually approved', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(true) // The app added a scope after the viewer consented: it must ask again // rather than silently minting a broader token. - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'jobs:run'])).toBe(false) }) it('does not leak one viewer or app to another', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['jobs:read'])).toBe(false) }) it('keeps the editor preview and the deployed app apart, and moves preview consent', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'], true)).toBe(false) - storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'], true)).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'])).toBe(false) movePreviewSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', 'u/a/real') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['users:read'], true)).toBe(true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['jobs:read'], true)).toBe(true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true)).toBe(false) + }) + + it('never asks for the identity scope every token carries', () => { + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'users:read'])).toBe(true) }) it('treats unreadable storage as no consent', () => { localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + // Nothing to ask for: storage must not turn that into an empty prompt. + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) }) }) diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts index 238f1b7278..a4600979ba 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -1,7 +1,12 @@ // Frontend-SDK permissions for raw apps: the curated scopes an app author may // declare in `policy.frontend_sdk_scopes` (mirrors FRONTEND_SDK_ALLOWED_SCOPES -// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side -// consent persistence for the permission banner. +// in the backend `apps.rs` — both lists must stay in sync, except for the +// implicit scope below), plus the viewer-side consent persistence for the +// permission banner. + +/** Never toggled nor asked for: every SDK token reaches whoami without it, and whoami + * returns what the bundle already gets in `window.ctx`. */ +export const IMPLICIT_SDK_SCOPE = 'users:read' export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [ { @@ -14,11 +19,6 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s label: 'Read jobs and results', description: 'Read jobs and their results, and list the runs the viewer can see' }, - { - value: 'users:read', - label: 'Read your identity', - description: 'Call whoami (Read the viewer username, email, groups, is_super_admin...)' - }, { value: 'resources:read', label: 'Read resources', @@ -59,10 +59,18 @@ function sdkConsentKey(viewer: string, workspace: string, path: string, preview: return `wm_sdk_consent:${preview ? 'p:' : ''}${viewer}:${workspace}:${path}` } +/** The declared scopes the viewer is asked to approve. */ +export function sdkScopesToConfirm(scopes: string[]): string[] { + return scopes.filter((s) => s !== IMPLICIT_SDK_SCOPE) +} + /** True when `approved` (a consent the viewer gave) covers every scope the app now * declares. Fewer scopes need no new consent; one more does. */ export function sdkConsentCovers(approved: unknown, scopes: string[]): boolean { - return Array.isArray(approved) && scopes.every((s) => approved.includes(s)) + const needed = sdkScopesToConfirm(scopes) + return ( + needed.length === 0 || (Array.isArray(approved) && needed.every((s) => approved.includes(s))) + ) } /** True when a previously stored "do not ask again" consent covers every @@ -80,7 +88,7 @@ export function hasStoredSdkConsent( ) return sdkConsentCovers(stored, scopes) } catch (_) { - return false + return sdkConsentCovers(null, scopes) } } diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 2c12e514b9..b3b70cfaa2 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -608,6 +608,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1343,7 +1347,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1460,7 +1464,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9aee298999..69565dd9d4 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1148,7 +1148,7 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -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. +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\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them. 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. diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 7a5667c106..df5e7ece6a 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -483,14 +483,14 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne ## Sandbox isolation and frontend API access -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. +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`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them. 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 sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 1323e4228a..dc70d0b94d 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -251,7 +251,7 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne ## Sandbox isolation and frontend API access -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. +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`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them. 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. @@ -262,7 +262,7 @@ In `raw_app.yaml`: ```yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.