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 == ''}wm_coep flag