From 6064aecdecc49facde3870446b570abd8e8124b2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 29 Sep 2026 16:52:49 +0200 Subject: [PATCH] feat: stop cross-origin isolating the raw app editor (#11411) * feat: stop cross-origin isolating the raw app editor Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U * docs: drop stale raw app editor reload comments Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U * feat: drop wm_coep from the default raw app embed snippet Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U * chore: pin the UI builder that type-checks without cross-origin isolation Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U * docs: name the proxy-isolated case behind the remaining isolation checks Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/static_assets.rs | 46 ++++------ frontend/scripts/ui_builder_artifact.json | 4 +- frontend/serve_ui_builder.sh | 2 - .../apps/editor/AppEditorHeaderDeploy.svelte | 26 ++---- .../chat/WebSearchSourcesDisplay.svelte | 6 +- .../chat/global/rawAppBundlerBridge.ts | 5 +- .../components/flows/CreateActionsApp.svelte | 3 +- .../components/home/CreateActionsMenu.svelte | 3 +- .../raw_apps/RawAppCoepWarning.svelte | 92 ------------------- .../components/raw_apps/RawAppEditor.svelte | 45 +++++---- .../components/raw_apps/RawAppPreview.svelte | 5 +- .../src/routes/(root)/(logged)/+layout.svelte | 34 +------ .../apps_raw/edit/[...path]/+page.svelte | 5 +- .../(logged)/flows/get/[...path]/+page.svelte | 3 +- .../scripts/get/[...hash]/+page.svelte | 3 +- frontend/static/_headers | 3 - frontend/vite.config.js | 11 +-- functions/api/[[path]].ts | 2 - 18 files changed, 67 insertions(+), 231 deletions(-) delete mode 100644 frontend/src/lib/components/raw_apps/RawAppCoepWarning.svelte delete mode 100644 frontend/static/_headers diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 22645a2ac4..66dc4da9df 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -51,30 +51,17 @@ const TWO_HUNDRED: &str = "200.html"; /// Check if the original path requires cross-origin isolation headers. /// /// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js` -/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte` -/// point here): the headers are needed for SharedArrayBuffer and TypeScript -/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at -/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them: -/// COEP `require-corp` blocks the viewed app's cross-origin subresources -/// (external images, embeds) that lack CORP — and since headers stick to the -/// document, apps would break on a page reload while working when reached via -/// client-side navigation. -/// -/// 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. +/// points here). Only public apps (`/public/` and custom paths `/a/`) ever get +/// them, and only when they 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 COEP `require-corp` blocks subresources without +/// CORP (external image URLs, embeds), and since headers stick to the document, +/// isolating an SPA route leaks into every page reached from it client-side. #[cfg(feature = "static_frontend")] fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { - // no trailing slash on edit/add: matches the +layout.svelte guards - original_path.starts_with("/apps_raw/edit") - || original_path.starts_with("/apps_raw/add") - || original_path.starts_with("/ui_builder/") - || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) - && query_has_flag(query, "wm_coep")) + (original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep") } /// Returns true if `query` contains the given flag key (with or without a @@ -98,8 +85,6 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< .header(header::CONTENT_TYPE, mime.as_ref()) .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - // 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, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") @@ -167,12 +152,13 @@ mod tests { #[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("/apps_raw/add", None)); - assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); - - // the raw app viewer must NOT be isolated + // the raw app editor, its UI builder frames and the viewer are never isolated + assert!(!needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(!needs_cross_origin_isolation("/apps_raw/add", None)); + assert!(!needs_cross_origin_isolation( + "/ui_builder/index.html", + None + )); assert!(!needs_cross_origin_isolation( "/apps_raw/get/u/foo/bar", None diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 68c1c558b1..8940f265ce 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "60b6904", - "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" + "version": "9fddc52", + "sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522" } diff --git a/frontend/serve_ui_builder.sh b/frontend/serve_ui_builder.sh index e396c6f1b4..4bf7e48a4d 100755 --- a/frontend/serve_ui_builder.sh +++ b/frontend/serve_ui_builder.sh @@ -9,8 +9,6 @@ os.chdir('ui_builder_serve') from http.server import HTTPServer, SimpleHTTPRequestHandler class H(SimpleHTTPRequestHandler): def end_headers(self): - self.send_header('Cross-Origin-Opener-Policy', 'same-origin') - self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') self.send_header('Cross-Origin-Resource-Policy', 'cross-origin') super().end_headers() HTTPServer(('', 4000), H).serve_forever() diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 4f27eb6b26..d062247fa4 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -76,10 +76,9 @@ hideSecretUrl?: boolean preserveOnBehalfOf?: boolean labels?: string[] | undefined - // 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). + // Only raw apps are offered the `wm_coep` embedding hint: on a classic + // (low-code) app COEP breaks no-CORP cross-origin subresources (external + // images, {@html} embeds, CDN imports) that the app cannot opt out of. rawApp?: boolean /** True while the editor is on a draft-only URL (`/edit/u/{user}/draft_{uuid}` * with no deployed row yet). Suppresses the public-secret-URL fetch @@ -195,15 +194,9 @@ // token as a seatless guest. Uses the custom URL when set, else the public secret URL. let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref) - // 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 `` + return `` } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ @@ -568,11 +561,12 @@ {#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. + To embed it in a cross-origin isolated page, add wm_coep=on + 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. to the URL. {/if} (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) {:else} diff --git a/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte b/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte index 0808d6e172..191f6c7267 100644 --- a/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte @@ -43,9 +43,9 @@ // consulted hostname to a third party from the user's browser — an accepted // tradeoff for now (blocked/air-gapped environments degrade to the Globe // icon via onerror). Hit gstatic directly rather than www.google.com/s2/ - // favicons: the app is served with COEP require-corp, and the s2 redirect - // hop carries no Cross-Origin-Resource-Policy header, so the browser blocks - // the image. The gstatic endpoint itself responds with CORP: cross-origin. + // favicons: the s2 redirect hop carries no Cross-Origin-Resource-Policy + // header, so a cross-origin isolated page would block the image. The + // gstatic endpoint itself responds with CORP: cross-origin. function faviconUrl(hostname: string): string { return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(hostname)}&size=64` } diff --git a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts index dd7c04c29a..62dce8af5c 100644 --- a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts +++ b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts @@ -132,9 +132,8 @@ export function bundleRawAppFiles({ iframe.title = 'Raw app bundler' iframe.tabIndex = -1 - // No `credentialless`: /ui_builder/* is itself served with COEP=require-corp, so it - // embeds in isolated pages as is, while a credentialless frame gets an empty cookie - // jar, which would leave its npm installer unauthenticated against /api/w/*. + // No `credentialless`: a credentialless frame gets an empty cookie jar, which would + // leave its npm installer unauthenticated against /api/w/*. iframe.style.position = 'fixed' iframe.style.width = '0' iframe.style.height = '0' diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 7ae5e9850c..d75d1b33d3 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -23,8 +23,7 @@ async function importRaw() { const parsed = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw) if (appKind === 'fullcode') { - // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), - // so the in-memory importStore would be lost. Use sessionStorage instead. + // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) await goto('/apps_raw/add') } else { diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 34e1de8494..a400a73df0 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -405,8 +405,7 @@ importScriptStore.set(parsed) await goto(`${base}/scripts/add?import=true`) } else if (importKind === 'app-fullcode') { - // /apps_raw/add does a full reload (cross-origin isolation), so the in-memory - // store would be lost — hand the payload over via sessionStorage instead. + // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) await goto(`${base}/apps_raw/add`) } else { diff --git a/frontend/src/lib/components/raw_apps/RawAppCoepWarning.svelte b/frontend/src/lib/components/raw_apps/RawAppCoepWarning.svelte deleted file mode 100644 index fca969de27..0000000000 --- a/frontend/src/lib/components/raw_apps/RawAppCoepWarning.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - - -
-

- The app editor runs in a cross-origin isolated context (COOP/COEP headers). This is - required for SharedArrayBuffer, which powers the TypeScript language workers and - lets the editor build and preview your frontend live in the browser. -

-

- A side effect is that the browser refuses to load cross-origin resources (images, scripts, - stylesheets, media…) unless the remote server explicitly opts in with CORS or a - Cross-Origin-Resource-Policy header. Resources from servers that don't are - blocked in the editor preview only. When this is the cause, the browser console shows - ERR_BLOCKED_BY_RESPONSE — a plain 404 or DNS error instead means the URL itself is - broken and will fail on the deployed app too. -

-

- The deployed app is served without these headers, so the same resources load normally there — - open the deployed app link to verify. If you control the remote server, sending - Cross-Origin-Resource-Policy: cross-origin makes the resource load in the editor too. -

-
-
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 78d3ba8102..98c436b0bd 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -29,7 +29,6 @@ } from './utils' import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom' import InlineElementPrompt from './InlineElementPrompt.svelte' - import RawAppCoepWarning from './RawAppCoepWarning.svelte' import DarkModeObserver from '../DarkModeObserver.svelte' import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant' import RawAppSidebar from './RawAppSidebar.svelte' @@ -383,7 +382,6 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' let previewIframe: HTMLIFrameElement | undefined = $state(undefined) - let coepWarning: RawAppCoepWarning | undefined = $state(undefined) let previewIframeLoaded = $state(false) let lastBuild: { css: string; js: string } | undefined = undefined // Detached preview tab/window rendering the same app-preview bundle as the @@ -396,6 +394,10 @@ let externalPreviewReady = $state(false) let inspectorEnabled = $state(false) let bundlerType: 'esbuild' | 'rolldown' = $state('esbuild') + // rolldown's wasm build uses shared memory, which only exists in a cross-origin + // isolated document. Windmill never serves the editor isolated, so the switch + // only shows behind a reverse proxy that adds COOP/COEP to the whole site. + const rolldownAvailable = globalThis.crossOriginIsolated === true // Build/bundler logs forwarded from the UI Builder iframe. We render // them as an overlay inside the preview pane (right side) so they're @@ -1338,9 +1340,6 @@ ) { externalPreviewReady = true feedExternalPreview() - // The detached window is cross-origin isolated like the inline preview, - // so blocked external resources warrant the same COEP warning. - coepWarning?.attachTo(externalPreviewWindow) return } @@ -1677,9 +1676,6 @@ win.addEventListener('load', () => { externalPreviewReady = true feedExternalPreview() - // Attach here too: against an artifact that predates the handshake, this - // is the only place the freshly opened window is ever seen loaded. - coepWarning?.attachTo(win) }) } @@ -2620,21 +2616,23 @@ > {#snippet trailing()}
- + {#if rolldownAvailable} + + {/if}