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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166irwM15U8vZjc58MsU54U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-29 16:52:49 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2f87bc6a2d
commit 6064aecdec
18 changed files with 67 additions and 231 deletions
+16 -30
View File
@@ -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
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "60b6904",
"sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34"
"version": "9fddc52",
"sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522"
}
-2
View File
@@ -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()
@@ -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 `<iframe src="${finalUrl}" title="Windmill app" width="100%" height="600"></iframe>`
return `<iframe src="${url}" title="Windmill app" width="100%" height="600"></iframe>`
}
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
@@ -568,11 +561,12 @@
{#if embedMode}
Paste this iframe snippet into another app.
{#if rawApp}
The <code>wm_coep</code> flag <Tooltip
>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.</Tooltip
> lets it load inside a cross-origin-isolated page.
To embed it in a cross-origin isolated page, add <code>wm_coep=on</code>
<Tooltip
>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.</Tooltip
> to the URL.
{/if}
(if requiring login, top-level domain of embedding app must be the same as the one of Windmill)
{:else}
@@ -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`
}
@@ -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'
@@ -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 {
@@ -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 {
@@ -1,92 +0,0 @@
<script lang="ts">
import Modal from '../common/modal/Modal.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
/** Inline preview iframe rendering the user's app (same-origin app-preview.html) */
iframe: HTMLIFrameElement | undefined
}
let { iframe }: Props = $props()
let open = $state(false)
// One toast per remote origin — a page pulling many assets from one host
// should not stack toasts.
const warnedOrigins = new Set<string>()
const RESOURCE_TAGS = new Set(['IMG', 'SCRIPT', 'LINK', 'AUDIO', 'VIDEO', 'SOURCE', 'IFRAME'])
// The editor is cross-origin isolated (COEP require-corp, needed for the
// SharedArrayBuffer-based TS workers), and that extends to the nested preview:
// cross-origin resources lacking CORS/CORP headers are blocked there while the
// deployed app (served without COEP) loads them fine — warn instead of staying silent.
function onResourceError(e: Event) {
// Elements belong to the preview window's realm, so no instanceof checks —
// duck-type instead. Resource load errors don't bubble but do capture
// through the window; runtime ErrorEvents target the window itself and are
// skipped by the tagName check.
const el = e.target as (Element & { currentSrc?: string; src?: string; href?: string }) | null
if (!el?.tagName || !RESOURCE_TAGS.has(el.tagName)) return
const raw = el.currentSrc || el.src || el.href
if (typeof raw !== 'string' || !raw) return
let url: URL
try {
url = new URL(raw)
} catch {
return
}
// Same-origin failures (plain 404s) and non-http(s) schemes are not COEP blocks.
if (!/^https?:$/.test(url.protocol) || url.origin === window.location.origin) return
if (warnedOrigins.has(url.origin)) return
warnedOrigins.add(url.origin)
// `error` events carry no failure reason, so a 404/DNS failure on a
// cross-origin URL looks identical to a COEP block — hedge the wording.
sendUserToast(
`Cross-origin resource (${url.host}) failed to load — likely the editor's COEP/CORS isolation. A valid URL will still load on the deployed app.`,
'warning',
[{ label: 'Read more', callback: () => (open = true) }],
undefined,
10000
)
}
export function attachTo(win: Window | null | undefined) {
win?.addEventListener('error', onResourceError, true)
}
$effect(() => {
const el = iframe
if (!el) return
const attach = () => attachTo(el.contentWindow)
el.addEventListener('load', attach)
// The iframe may already be loaded when this mounts; addEventListener
// dedupes the (handler, capture) pair, so the load event re-firing on the
// same window cannot double-attach.
if (el.contentDocument?.readyState === 'complete') attach()
return () => el.removeEventListener('load', attach)
})
</script>
<Modal bind:open title="Cross-origin resources in the editor preview" kind="X">
<div class="flex flex-col gap-3">
<p>
The app editor runs in a <b>cross-origin isolated</b> context (COOP/COEP headers). This is
required for <code>SharedArrayBuffer</code>, which powers the TypeScript language workers and
lets the editor build and preview your frontend live in the browser.
</p>
<p>
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
<code>Cross-Origin-Resource-Policy</code> header. Resources from servers that don't are
blocked <b>in the editor preview only</b>. When this is the cause, the browser console shows
<code>ERR_BLOCKED_BY_RESPONSE</code> — a plain 404 or DNS error instead means the URL itself is
broken and will fail on the deployed app too.
</p>
<p>
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
<code>Cross-Origin-Resource-Policy: cross-origin</code> makes the resource load in the editor too.
</p>
</div>
</Modal>
@@ -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()}
<div class="flex items-center gap-1 px-2">
<Button
variant="subtle"
unifiedSize="sm"
title="Switch bundler"
onClick={() => {
const next = bundlerType === 'esbuild' ? 'rolldown' : 'esbuild'
bundlerType = next
iframe?.contentWindow?.postMessage(
{ type: 'setBundlerType', bundlerType: next },
'*'
)
}}
>
{bundlerType}
</Button>
{#if rolldownAvailable}
<Button
variant="subtle"
unifiedSize="sm"
title="Switch bundler"
onClick={() => {
const next = bundlerType === 'esbuild' ? 'rolldown' : 'esbuild'
bundlerType = next
iframe?.contentWindow?.postMessage(
{ type: 'setBundlerType', bundlerType: next },
'*'
)
}}
>
{bundlerType}
</Button>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
@@ -2704,7 +2702,6 @@
src={PREVIEW_SHELL_URL}
class="w-full flex-1 block"
></iframe>
<RawAppCoepWarning bind:this={coepWarning} iframe={previewIframe} />
{#if buildError}
<!-- top-12 clears the tab bar; `before:bg-surface` backs the
Alert's translucent red; `isolate` pins the pseudo's stacking context. -->
@@ -94,8 +94,9 @@
// to the wrapper document: under a COEP `require-corp` embedder, a nested
// document is only allowed to load if it asserts COEP itself, so the
// backend adds the header when the flag is present. Also request it when
// this document is itself cross-origin isolated (e.g. the raw app editor)
// — the wrapper would otherwise be blocked outright, URL flag or not.
// this document is itself cross-origin isolated without the flag (a proxy
// adding COOP/COEP to the whole site) — the wrapper would otherwise be
// blocked outright.
const coep =
new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated
? 'wm_coep=1&'
@@ -422,8 +422,7 @@
// Inside a sessions-preview iframe, hand an editor-route navigation up to the
// parent so it mounts the in-process editor (sharing the session runtime)
// instead of booting a second, disconnected editor in this frame. Cancel so
// the heavy editor never mounts here at all. Runs before the apps_raw reload
// below so a raw-app editor promotes rather than full-reloading the iframe.
// the heavy editor never mounts here at all.
if (isSessionPreviewFrame()) {
const target = previewEditorTarget(navigation.to?.url)
if (target) {
@@ -463,37 +462,6 @@
return
}
}
// Force page reload when navigating to /apps_raw/add or /apps_raw/edit
// This ensures the cross-origin isolation headers are fetched from the server
// which are required for SharedArrayBuffer and TypeScript workers to work correctly
const toPath = navigation.to?.url.pathname
const currentPath = navigation.from?.url.pathname
const isEditorPath = (p: string | undefined) =>
!!p && (p.startsWith('/apps_raw/add') || p.startsWith('/apps_raw/edit'))
if (isEditorPath(toPath)) {
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
// we need a full reload to fetch them for the editor.
if (!currentPath?.startsWith('/apps_raw/') || currentPath?.startsWith('/apps_raw/get/')) {
navigation.cancel()
window.location.href = navigation.to!.url.href
}
} else if (toPath && isEditorPath(currentPath)) {
// Reverse of the guard above: leaving the isolated editor document must
// also fully reload, or its COEP header sticks for the rest of the SPA
// session and blocks CORP-less cross-origin subresources (e.g. images in
// a viewed app — see needs_cross_origin_isolation in static_assets.rs).
// Key off the path, never `window.crossOriginIsolated`: a deployment may
// isolate the whole site (frontend/static/_headers does, for Cloudflare
// Pages), and there the flag is true on every page — turning every
// navigation into a full page load, while the reload it forces cannot
// clear an isolation the next document asserts too. Among the routes this
// layout governs, only the editor is served the headers, so entering it is
// the only way into an isolated document here.
navigation.cancel()
window.location.href = navigation.to!.url.href
}
})
let innerWidth = $state(BROWSER ? window.innerWidth : 2000)
@@ -233,9 +233,8 @@
// Explicit path seed: the fork-a-draft handoff re-homes the source
// path into the forker's namespace and passes it here.
const pathParam = page.url.searchParams.get('seed_path')
// One-shot YAML/JSON import handoff. Carried via $importStore, or
// sessionStorage when /apps_raw's full page reload would drop in-memory
// state. Wrapped exports carry { summary, value, policy }; bare ones the value.
// One-shot YAML/JSON import handoff, carried via $importStore or
// sessionStorage. Wrapped exports carry { summary, value, policy }; bare ones the value.
let importRaw: any = $importStore
if ($importStore) {
$importStore = undefined
@@ -371,8 +371,7 @@
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
// /apps_raw/add hard-reloads (cross-origin isolation), so the
// in-memory importStore would be dropped; hand off via sessionStorage.
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await goto('/apps_raw/add')
},
@@ -534,8 +534,7 @@
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
// /apps_raw/add hard-reloads (cross-origin isolation), so the
// in-memory importStore would be dropped; hand off via sessionStorage.
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await goto('/apps_raw/add')
},
-3
View File
@@ -1,3 +0,0 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
+3 -8
View File
@@ -199,17 +199,14 @@ function isolateAuthCookie(proxy) {
// Cross-origin isolation headers, scoped to mirror the production predicate —
// see `needs_cross_origin_isolation` in backend/windmill-api/src/static_assets.rs
// for which paths need them and why the raw app viewer must be excluded.
// for why only opted-in public apps get them.
// `enforce: 'pre'` so these headers are set before SvelteKit's sirv static
// handler serves `static/` files and ends the response without calling next().
function needsCrossOriginIsolation(url) {
const [path, query = ''] = url.split('?')
return (
path.startsWith('/apps_raw/edit') ||
path.startsWith('/apps_raw/add') ||
path.startsWith('/ui_builder/') ||
((path.startsWith('/public/') || path.startsWith('/a/')) &&
new URLSearchParams(query).has('wm_coep'))
(path.startsWith('/public/') || path.startsWith('/a/')) &&
new URLSearchParams(query).has('wm_coep')
)
}
@@ -313,8 +310,6 @@ const config = {
target: 'http://localhost:4000',
changeOrigin: true,
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Resource-Policy': 'cross-origin'
}
}
-2
View File
@@ -44,8 +44,6 @@ export async function onRequest(context) {
);
newResponse.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
newResponse.headers.set("Cross-Origin-Opener-Policy", "same-origin");
newResponse.headers.set("Cross-Origin-Embedder-Policy", "require-corp");
return newResponse;
} catch (e) {
return new Response(e.message, { status: 500 });