fix(apps): make public apps opt into cross-origin isolation via wm_coep (GIT-884) (#9374)

* fix(apps): make public apps opt into cross-origin isolation via wm_coep

Public app pages served at /public/* and custom paths /a/* were not
getting the COEP/COOP/CORP headers, so they were blocked when embedded
as an iframe inside a cross-origin-isolated page (e.g. another raw app,
which sets Cross-Origin-Embedder-Policy: require-corp). A nested
document loaded into a require-corp context must itself set COEP for
the iframe to load.

Rather than applying the isolation headers to all public pages (which
would also force COEP on classic apps and break subresources without
CORP, e.g. external image URLs or embeds), public apps now opt in via
a `wm_coep` query param on the embed URL:

    <iframe src="https://<domain>/public/<ws>/<secret>?wm_coep=on">

The app publish drawer gains a URL/Embed toggle: "URL" shows the plain
shareable link (param-free), "Embed" shows a ready-to-copy iframe
snippet with wm_coep baked in, so the flag is discoverable exactly when
embedding and absent otherwise.

`wm_coep` is consumed internally and stripped from the app `query`
context so it doesn't collide with app-defined params. Only params we
own are stripped (an explicit set), not the whole `wm_` prefix.

Fixes GIT-884

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nit

* nit

* fix(apps): only bake wm_coep into embed snippet for raw apps

AppEditorHeaderDeploy is shared by the classic (AppEditorHeader) and raw
(RawAppEditorHeader) deploy drawers. The embed snippet unconditionally
appended ?wm_coep=on, which for a classic/low-code app forces COEP
require-corp on the document and breaks no-CORP cross-origin subresources
(external <img> in AppImage/AppStatCard/AppNavbar, {@html} embeds in
AppHtml, CDN import() in AppCustomComponent) — the exact regression the
opt-in design avoids.

Add a `rawApp` prop (default false); the raw header passes rawApp. The
flag is appended only for raw apps; classic apps get a plain iframe
snippet, and the wm_coep helper text is shown only for raw apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-30 12:05:19 +02:00
committed by GitHub
parent f300a716a9
commit 2c0c2c467f
7 changed files with 150 additions and 18 deletions
+85 -9
View File
@@ -39,23 +39,43 @@ pub struct StaticFile(Uri);
impl IntoResponse for StaticFile {
fn into_response(self) -> Response<Body> {
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<Body> {
/// 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<Body> {
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<Body> {
// 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<Body> {
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));
}
}
@@ -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',
@@ -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 `<iframe src="${finalUrl}" title="Windmill app" width="100%" height="600"></iframe>`
}
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
@@ -253,12 +270,34 @@
{#if appPath == ''}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<ClipboardPanel content={secretUrlHref} size="md" />
<div class="flex justify-end mb-1">
<Toggle
size="xs"
checked={embedMode}
on:change={(e) => (embedMode = e.detail)}
options={{ left: 'URL', right: 'Embed' }}
/>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
size="md"
/>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
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 <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.
{/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 <b>Embed</b> to get an iframe snippet.
{/if}
</div>
<div class="mt-4">
@@ -305,7 +344,10 @@
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel content={fullCustomUrl} size="md" />
<ClipboardPanel
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
size="md"
/>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
@@ -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}
@@ -837,6 +837,7 @@
{appPath}
{onLatest}
{savedApp}
rawApp
bind:summary
bind:customPath
bind:deploymentMsg
+14 -1
View File
@@ -1186,9 +1186,22 @@ export function isCodeInjection(expr: string | undefined): boolean {
return dynamicTemplateRegex.test(expr)
}
export function urlParamsToObject(params: URLSearchParams): Record<string, string> {
// 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<string, string> {
const result: Record<string, string> = {}
params.forEach((value, key) => {
if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) {
return
}
result[key] = value
})
return result
@@ -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 ?? ''}