diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4e1f516570..7ebc26b190 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3742e0659c5e97aab03b9efeea14cd94a3ac658a +7dc7ce5ff98e1eb7cbed16758ac61e76542edb0b diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ae05dcb985..75b2d7bec0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7425,6 +7425,22 @@ paths: workspace_id: type: string + /apps_u/embed_token_by_custom_path/{custom_path}: + get: + summary: get a scoped embed token for a public app (by custom path) + operationId: getAppEmbedTokenByCustomPath + tags: + - app + parameters: + - $ref: "#/components/parameters/CustomPath" + responses: + "200": + description: embed token + content: + application/json: + schema: + $ref: "#/components/schemas/EmbedTokenResponse" + /scripts/hub/get/{path}: get: summary: get hub script content by path @@ -10543,6 +10559,23 @@ paths: schema: $ref: "#/components/schemas/AppWithLastVersion" + /w/{workspace}/apps_u/embed_token/{path}: + get: + summary: get a scoped embed token for a public app (by secret) + operationId: getAppEmbedToken + tags: + - app + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: embed token + content: + application/json: + schema: + $ref: "#/components/schemas/EmbedTokenResponse" + /w/{workspace}/apps_u/public_resource/{path}: get: summary: get public resource @@ -26946,6 +26979,24 @@ components: - edited_at + EmbedTokenResponse: + type: object + properties: + token: + type: string + nullable: true + description: >- + Narrowly-scoped token the embedder hands the app iframe. Null for + fully anonymous access. + public_app_domain: + type: string + nullable: true + description: Domain on which the app iframe must be served, if configured. + expiration: + type: string + format: date-time + nullable: true + AppWithLastVersion: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index de55cb87ff..7fcdb45b45 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -72,6 +72,7 @@ use windmill_common::{ use windmill_object_store::object_store_reexports::{Attribute, Attributes}; use windmill_store::resources::get_resource_value_interpolated_internal; +use windmill_api_auth::{create_token_internal, NewToken}; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel}; @@ -132,6 +133,7 @@ pub fn unauthed_service() -> Router { .route("/delete_s3_file", delete(delete_s3_file_from_app)) .route("/download_s3_file/{*path}", get(download_s3_file_from_app)) .route("/public_app/{secret}", get(get_public_app_by_secret)) + .route("/embed_token/{secret}", get(get_app_embed_token)) .route("/public_resource/{*path}", get(get_public_resource)) .route("/get_data/v/{*id}", get(get_raw_app_data)) } @@ -886,6 +888,141 @@ async fn get_public_app_by_secret( Ok(Json(app)) } +/// Scopes granted to a short-lived "app embed token". This is the token the +/// app-embedder page hands the app iframe at startup (WIN-2006). It is the +/// token-level replacement for the host-based `public_app_domain` route +/// whitelist: instead of restricting which routes a domain may hit, we restrict +/// which routes the *token* may hit, so that even an XSS-compromised app +/// document can only reach the endpoints an app legitimately needs. +/// +/// These mirror the former public-app route whitelist: +/// - `apps:run` → `apps_u/public_app`, `get_data`, `public_resource` (read) +/// and `execute_component` (run). Note: `Run`/`Read` only — +/// it does NOT grant `apps:write`, so the token cannot reach +/// app-management routes (`apps/update`, `apps/delete`, ...). +/// - `jobs:read` → `jobs_u/getupdate_sse`, completed-job results (poll app jobs). +/// - `resources:read`→ `resources/list`, `resources/type/*`, `resources/exists`. +/// - `users:read` → `users/whoami`. +/// - `folders:read` → `folders/listnames`. +pub const APP_EMBED_SCOPES: [&str; 5] = [ + "apps:run", + "jobs:read", + "resources:read", + "users:read", + "folders:read", +]; + +/// How long an app embed token stays valid. The embedder re-mints on demand +/// (e.g. after a `401` from the iframe) so this can stay short. +const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12; + +#[derive(Serialize)] +pub struct EmbedTokenResponse { + /// Narrowly-scoped token for the iframe. `None` for fully anonymous access + /// (the iframe then calls the public endpoints anonymously). + pub token: Option, + /// Domain on which the app iframe must be served, if configured. + pub public_app_domain: Option, + pub expiration: Option>, +} + +/// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller +/// is authenticated, and report the configured public app domain. When +/// `opt_authed` is `None` (anonymous access to an anonymous app), no token is +/// minted and the iframe relies on the public endpoints. +pub async fn mint_app_embed_token( + db: &DB, + w_id: &str, + app_path: &str, + opt_authed: Option<&ApiAuthed>, +) -> Result { + let public_app_domain = crate::public_app_layer::public_app_domain(); + + let token_and_exp = if let Some(authed) = opt_authed { + let expiration = + chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS); + let scopes = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect(); + let token_config = NewToken::new( + Some(format!("embed_app:{app_path}")), + Some(expiration), + None, + Some(scopes), + Some(w_id.to_string()), + // Never let an embed token gain write capability the caller's own + // session lacks. + Some(authed.read_only), + ); + let mut tx = db.begin().await?; + let token = create_token_internal(&mut *tx, db, authed, token_config).await?; + tx.commit().await?; + Some((token, expiration)) + } else { + None + }; + + Ok(EmbedTokenResponse { + token: token_and_exp.as_ref().map(|(t, _)| t.clone()), + public_app_domain, + expiration: token_and_exp.map(|(_, e)| e), + }) +} + +/// Issue an embed token for a public app addressed by its (secret) share id. +/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are +/// reachable without auth, otherwise the caller must be logged in and have read +/// access to the app. +async fn get_app_embed_token( + OptAuthed(opt_authed): OptAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Path((w_id, secret)): Path<(String, String)>, +) -> JsonResult { + let id = get_id_from_secret(&db, &w_id, secret, None).await?; + + let app = sqlx::query!( + "SELECT path, policy::text as policy FROM app WHERE id = $1 AND workspace_id = $2", + id, + &w_id + ) + .fetch_optional(&db) + .await?; + let app = not_found_if_none(app, "App", id.to_string())?; + let policy_str = app + .policy + .ok_or_else(|| Error::internal_err("App policy missing".to_string()))?; + let policy = serde_json::from_str::(&policy_str).map_err(to_anyhow)?; + + let authed_for_token = if matches!(policy.execution_mode, ExecutionMode::Anonymous) { + // Anonymous app: still mint a scoped token if the viewer happens to be + // logged in (so the app sees their identity), otherwise stay anonymous. + opt_authed + } else { + let authed = opt_authed.ok_or_else(|| { + Error::NotAuthorized( + "App visibility does not allow public access and you are not logged in".to_string(), + ) + })?; + let mut tx = user_db.begin(&authed).await?; + let is_visible = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)", + id, + &w_id + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + if !is_visible.unwrap_or(false) { + return Err(Error::NotAuthorized( + "App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(), + )); + } + Some(authed) + }; + + let resp = mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await?; + Ok(Json(resp)) +} + async fn get_id_from_secret( db: &DB, w_id: &str, @@ -3422,3 +3559,52 @@ async fn build_args( job_id, )) } + +#[cfg(test)] +mod embed_token_tests { + use super::APP_EMBED_SCOPES; + use windmill_api_auth::scopes::check_scopes_for_route; + + /// The embed token must reach exactly the endpoints an app needs and nothing + /// else. This locks the allow/deny matrix that replaces the host-based + /// public-app route whitelist (WIN-2006). + #[test] + fn embed_scopes_allow_app_routes_and_deny_the_rest() { + let scopes: Vec = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect(); + let scopes = Some(scopes.as_slice()); + + // Allowed: the routes a running app legitimately calls. + let allowed = [ + ("/api/w/test/apps_u/public_app/secret", "GET"), + ("/api/w/test/apps_u/get_data/v/secret.js", "GET"), + ("/api/w/test/apps_u/public_resource/f/app_themes/t", "GET"), + ("/api/w/test/apps_u/execute_component/u/admin/app", "POST"), + ("/api/w/test/jobs_u/getupdate_sse/some-uuid", "GET"), + ("/api/w/test/users/whoami", "GET"), + ("/api/w/test/resources/list", "GET"), + ("/api/w/test/folders/listnames", "GET"), + ]; + for (path, method) in allowed { + assert!( + check_scopes_for_route(scopes, path, method).is_ok(), + "embed token should allow {method} {path}" + ); + } + + // Denied: anything outside what an app needs, including app management + // (apps:write is intentionally withheld) and other workspace domains. + let denied = [ + ("/api/w/test/apps/update/u/admin/app", "POST"), + ("/api/w/test/apps/delete/u/admin/app", "DELETE"), + ("/api/w/test/scripts/list", "GET"), + ("/api/w/test/variables/list", "GET"), + ("/api/w/test/resources/update/u/admin/r", "POST"), + ]; + for (path, method) in denied { + assert!( + check_scopes_for_route(scopes, path, method).is_err(), + "embed token should deny {method} {path}" + ); + } + } +} diff --git a/backend/windmill-api/src/public_app_layer.rs b/backend/windmill-api/src/public_app_layer.rs index 8a55e61289..5dee2ff661 100644 --- a/backend/windmill-api/src/public_app_layer.rs +++ b/backend/windmill-api/src/public_app_layer.rs @@ -14,6 +14,18 @@ lazy_static::lazy_static! { pub static ref PUBLIC_APP_DOMAIN: Option = std::env::var("PUBLIC_APP_DOMAIN").ok(); } +/// The domain on which embedded apps must be served (in an iframe), if configured. +/// +/// When set, the app-embedder page renders the app iframe with its `src` pointing +/// at this domain so that the app document lives on a separate origin and cannot +/// read the main domain's httponly session cookie. The embedder hands the iframe a +/// narrowly-scoped token via `postMessage` at startup. When unset, the iframe is +/// served same-origin (token-scoping still applies, but raw cookie-based XSS is not +/// fully prevented — see WIN-2006). +pub fn public_app_domain() -> Option { + PUBLIC_APP_DOMAIN.clone() +} + /// Middleware to restrict public app domain to whitelisted routes pub async fn public_app_domain_filter( req: axum::extract::Request, diff --git a/docs/app-iframe-isolation.md b/docs/app-iframe-isolation.md new file mode 100644 index 0000000000..34e2e76036 --- /dev/null +++ b/docs/app-iframe-isolation.md @@ -0,0 +1,100 @@ +# App iframe isolation & scoped embed tokens (WIN-2006) + +Published Windmill apps (and raw apps) render arbitrary, user-authored markup +and JavaScript. If that document runs on the same origin as the main Windmill +UI, an XSS payload in an app can read the httponly session cookie's privileges +(via same-origin `fetch`) and act as the logged-in user across the whole API. + +To contain this, **apps are always rendered inside an iframe**, and the iframe +is handed a **narrowly-scoped token** by its embedder at startup — never the +main session cookie. When a dedicated public app domain is configured, the +iframe also lives on a **separate origin**, which turns the containment into a +hard browser-enforced boundary. + +## Roles + +The same public-app route plays two roles, selected by the `wm_embed=1` query +param plus whether the window is framed: + +- **Embedder** (top-level window, main domain): authenticates the viewer using + the main-domain session cookie or a shared JWT, mints a scoped embed token, + and renders an ` +{/if} diff --git a/frontend/src/routes/a/[...path]/+page.svelte b/frontend/src/routes/a/[...path]/+page.svelte index 062f7ae8de..a9fdbb6f73 100644 --- a/frontend/src/routes/a/[...path]/+page.svelte +++ b/frontend/src/routes/a/[...path]/+page.svelte @@ -4,18 +4,19 @@ import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { setContext } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' import { getUserExt } from '$lib/user' - import { sendUserToast } from '$lib/toast' import { page } from '$app/state' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' + import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' - let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined) + let app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined = + $state(undefined) let notExists = $state(false) let noPermission = $state(false) let jwtError = $state(false) + let workspace: string | undefined = $state(undefined) function isJwt(t: string) { // simply check that the first part is a valid base64 encoded json @@ -44,37 +45,43 @@ } } - let workspace: string | undefined = $state(undefined) - async function loadApp() { - const parsedCustomPath = parseCustomPath(page.params.path ?? '') + const parsedCustomPath = parseCustomPath(page.params.path ?? '') + let refresh: (() => void) | undefined + + // Embedder side: validate access + mint a scoped token for the iframe. + async function fetchEmbedToken() { if (parsedCustomPath.jwt) { - const token = 'jwt_ext_' + parsedCustomPath.jwt - OpenAPI.TOKEN = token - setContext<{ token?: string }>('AuthToken', { token }) - jwtError = false + OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt } + return await AppService.getAppEmbedTokenByCustomPath({ + customPath: parsedCustomPath.path + }) + } + + // Viewer side: load the app + user using the embed token handed to the iframe. + async function loadApp() { try { app = await AppService.getPublicAppByCustomPath({ customPath: parsedCustomPath.path }) workspace = app.workspace_id - workspaceStore.set(app.workspace_id) + if (app.workspace_id) { + workspaceStore.set(app.workspace_id) + } noPermission = false notExists = false try { - userStore.set(await getUserExt(app.workspace_id)) - if (!$userStore && parsedCustomPath.jwt) { - jwtError = true - sendUserToast('Could not authentify user with jwt token', true) + if (app.workspace_id) { + userStore.set(await getUserExt(app.workspace_id)) } } catch (e) { console.warn('Anonymous user') } } catch (e) { if (e.status == 401) { - noPermission = true + refresh?.() } else { notExists = true } @@ -83,17 +90,24 @@ if (BROWSER) { setLicense() - loadApp() } - { + { + refresh = requestTokenRefresh loadApp() }} -> +> + {#snippet viewer()} + loadApp()} + > + {/snippet} + diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index c661aa48c6..03e47864cf 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -4,21 +4,19 @@ import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen' import { userStore } from '$lib/stores' - import { setContext } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' import { getUserExt } from '$lib/user' - import { sendUserToast } from '$lib/toast' import { page } from '$app/state' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' + import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined) let notExists = $state(false) let noPermission = $state(false) - let jwtError = $state(false) - function parseSecret(secret: string): { secret: string; jwt: string } { + function parseSecret(secret: string): { secret: string; jwt: string | undefined } { const parts = secret.split('/') return { secret: parts[0], @@ -27,18 +25,40 @@ } const parsedSecret = parseSecret(page.params.secret ?? '') + const workspace = page.params.workspace ?? '' + let refresh: (() => void) | undefined + + // Embedder side: validate access (using the main-domain session cookie or the + // shared JWT) and mint a scoped token for the iframe. + async function fetchEmbedToken() { + if (parsedSecret.jwt) { + OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt + } + return await AppService.getAppEmbedToken({ + workspace, + path: parsedSecret.secret + }) + } + + // Viewer side: load the app + user using the embed token handed to the iframe. async function loadApp() { + try { + userStore.set(await getUserExt(workspace)) + } catch (e) { + console.warn('Anonymous user') + } try { app = await AppService.getPublicAppBySecret({ - workspace: page.params.workspace ?? '', + workspace, path: parsedSecret.secret }) noPermission = false notExists = false } catch (e) { if (e.status == 401) { - noPermission = true + // Embed token missing/expired — ask the embedder for a fresh one. + refresh?.() } else { notExists = true } @@ -47,42 +67,24 @@ if (BROWSER) { setLicense() - loadAll() - } - - function loadAll() { - console.log('loadAll') - loadUser().then(() => { - loadApp() - }) - } - - async function loadUser() { - if (parsedSecret.jwt) { - const token = 'jwt_ext_' + parsedSecret.jwt - OpenAPI.TOKEN = token - setContext<{ token?: string }>('AuthToken', { token }) - jwtError = false - } - try { - userStore.set(await getUserExt(page.params.workspace ?? '')) - if (!$userStore && parsedSecret.jwt) { - jwtError = true - sendUserToast('Could not authentify user with jwt token', true) - } - } catch (e) { - console.warn('Anonymous user') - } } - { - loadAll() + { + refresh = requestTokenRefresh + loadApp() }} -> +> + {#snippet viewer()} + loadApp()} + > + {/snippet} +