diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 2b93d59bc0..35728bc693 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -39,23 +39,43 @@ pub struct StaticFile(Uri); impl IntoResponse for StaticFile { fn into_response(self) -> Response { let original_path = self.0.path(); + let query = self.0.query(); let path = original_path.trim_start_matches('/'); - serve_path(path, original_path) + serve_path(path, original_path, query) } } #[cfg(feature = "static_frontend")] const TWO_HUNDRED: &str = "200.html"; -/// Check if the original path requires cross-origin isolation headers +/// Check if the original path requires cross-origin isolation headers. +/// /// These headers are needed for SharedArrayBuffer and TypeScript workers -/// Only enabled for /apps_raw paths (raw app editor) +/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`). +/// +/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` +/// query param: a public (raw) app must set COEP to be embeddable as an iframe +/// inside a cross-origin-isolated page (which requires the embedded document to +/// also set COEP). It is opt-in rather than always-on because cross-origin +/// isolation also blocks subresources without CORP (e.g. external image URLs +/// or embeds used by classic apps), so we only enable it when the embedder +/// explicitly requests it. #[cfg(feature = "static_frontend")] -fn needs_cross_origin_isolation(original_path: &str) -> bool { - original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/") +fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { + original_path.starts_with("/apps_raw/") + || original_path.starts_with("/ui_builder/") + || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep")) } -fn serve_path(path: &str, original_path: &str) -> Response { +/// Returns true if `query` contains the given flag key (with or without a +/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`. +#[cfg(feature = "static_frontend")] +fn query_has_flag(query: Option<&str>, flag: &str) -> bool { + query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag))) +} + +fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response { if path.starts_with("api/") { return Response::builder().status(404).body(Body::empty()).unwrap(); } @@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response { // Add cross-origin isolation headers only for paths that need them // (apps_raw editor needs SharedArrayBuffer for TypeScript workers) - if needs_cross_origin_isolation(original_path) { + if needs_cross_origin_isolation(original_path, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") .header("Cross-Origin-Embedder-Policy", "require-corp") @@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response { None if path.starts_with("_app/") => { Response::builder().status(404).body(Body::empty()).unwrap() } - None => serve_path(TWO_HUNDRED, original_path), + None => serve_path(TWO_HUNDRED, original_path, query), } #[cfg(not(feature = "static_frontend"))] { - let _ = original_path; // suppress unused warning + let _ = (original_path, query); // suppress unused warning Response::builder().status(404).body(Body::empty()).unwrap() } } + +#[cfg(all(test, feature = "static_frontend"))] +mod tests { + use super::*; + + #[test] + fn test_query_has_flag() { + assert!(query_has_flag(Some("wm_coep"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep=on"), "wm_coep")); + assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep")); + assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep")); + assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep")); + assert!(!query_has_flag(Some(""), "wm_coep")); + assert!(!query_has_flag(None, "wm_coep")); + } + + #[test] + fn test_needs_cross_origin_isolation() { + // editor + bundler are always isolated, regardless of query + assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); + + // public apps (and custom paths) are isolated only when they opt in via wm_coep + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep") + )); + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep=on") + )); + assert!(needs_cross_origin_isolation( + "/a/ws/my/path", + Some("wm_coep=on") + )); + assert!(!needs_cross_origin_isolation("/public/ws/secret", None)); + assert!(!needs_cross_origin_isolation("/a/ws/my/path", None)); + assert!(!needs_cross_origin_isolation( + "/public/ws/secret", + Some("foo=1") + )); + + // unrelated paths never get the headers + assert!(!needs_cross_origin_isolation( + "/apps/get/foo", + Some("wm_coep") + )); + // `/api/` must not be caught by the `/a/` prefix + assert!(!needs_cross_origin_isolation( + "/api/version", + Some("wm_coep") + )); + assert!(!needs_cross_origin_isolation("/", None)); + } +} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 4e62110eff..d6b2a7818d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -165,7 +165,7 @@ groups: $userStore?.groups, username: $userStore?.username, name: $userStore?.name, - query: urlParamsToObject(new URL(window.location.href).searchParams), + query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }), hash: window.location.hash.substring(1), workspace: $workspaceStore, mode: 'editor', diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 649e67ef0d..02ce70f64f 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -35,7 +35,8 @@ newEditedPath = $bindable(), newPath, hideSecretUrl = false, - preserveOnBehalfOf = $bindable(false) + preserveOnBehalfOf = $bindable(false), + rawApp = false }: { policy: any setPublishState: () => void @@ -51,6 +52,11 @@ newPath: string hideSecretUrl?: boolean preserveOnBehalfOf?: boolean + // Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic + // (low-code) apps must NOT get the flag — it would force COEP on the + // document and break no-CORP cross-origin subresources (external images, + // {@html} embeds, CDN imports). + rawApp?: boolean } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) @@ -91,6 +97,17 @@ isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : '' }${customPath}` ) + + // When embedding a raw app in an iframe inside another Windmill app (or any + // cross-origin-isolated page), the embedded document must set COEP. The + // `wm_coep` flag opts the public app into the cross-origin isolation headers. + // Only raw apps get it — for classic (low-code) apps COEP would break + // no-CORP cross-origin subresources, so their snippet stays a plain iframe. + let embedMode = $state(false) + function toEmbedSnippet(url: string): string { + const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url + return `` + } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ workspace: $workspaceStore!, @@ -253,12 +270,34 @@ {#if appPath == ''} {:else if secretUrlHref} - +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ {:else} {/if}
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside + another Windmill app or any cross-origin-isolated page. Without it the browser blocks + the iframe. lets it load inside a cross-origin-isolated page. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this url directly, or switch to Embed to get an iframe snippet. + {/if}
@@ -305,7 +344,10 @@
Custom public URL
- +
{dirtyCustomPath ? customPathError : ''} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 48c3d5578d..fef6ba8fa3 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -145,7 +145,7 @@ name: $userStore?.name, groups: $userStore?.groups, username: $userStore?.username, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={effectiveWorkspace} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 495a4ebcc1..35878e789b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -837,6 +837,7 @@ {appPath} {onLatest} {savedApp} + rawApp bind:summary bind:customPath bind:deploymentMsg diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8fb6475806..2e6302fb6c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1186,9 +1186,22 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -export function urlParamsToObject(params: URLSearchParams): Record { +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers. +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { const result: Record = {} params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } result[key] = value }) return result diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index bd900b38f0..737ebb2e33 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -56,7 +56,7 @@ name: $userStore?.name, username: $userStore?.username, groups: $userStore?.groups, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={$workspaceStore ?? ''}