mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 ?? ''}
|
||||
|
||||
Reference in New Issue
Block a user