mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
* 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>
190 lines
7.3 KiB
Rust
190 lines
7.3 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use axum::{body::Body, extract::OriginalUri, http::Response, response::IntoResponse};
|
|
|
|
#[cfg(feature = "static_frontend")]
|
|
use axum::http::header;
|
|
#[cfg(feature = "static_frontend")]
|
|
use http::HeaderValue;
|
|
|
|
use hyper::Uri;
|
|
#[cfg(feature = "static_frontend")]
|
|
use mime_guess::mime;
|
|
#[cfg(feature = "static_frontend")]
|
|
use rust_embed::RustEmbed;
|
|
|
|
// Content Security Policy configuration
|
|
#[cfg(feature = "static_frontend")]
|
|
lazy_static::lazy_static! {
|
|
static ref CSP_POLICY: String = std::env::var("CSP_POLICY").unwrap_or_default();
|
|
}
|
|
|
|
// static_handler is a handler that serves static files from the
|
|
pub async fn static_handler(OriginalUri(original_uri): OriginalUri) -> StaticFile {
|
|
StaticFile(original_uri)
|
|
}
|
|
|
|
#[cfg(feature = "static_frontend")]
|
|
#[derive(RustEmbed)]
|
|
#[folder = "${FRONTEND_BUILD_DIR:-../../frontend/build/}"]
|
|
struct Asset;
|
|
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, query)
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "static_frontend")]
|
|
const TWO_HUNDRED: &str = "200.html";
|
|
|
|
/// Check if the original path requires cross-origin isolation headers.
|
|
///
|
|
/// These headers are needed for SharedArrayBuffer and TypeScript workers
|
|
/// (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, 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"))
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
#[cfg(feature = "static_frontend")]
|
|
match Asset::get(path) {
|
|
Some(content) => {
|
|
let body = Body::from(content.data);
|
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
|
let mut res = Response::builder()
|
|
.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")
|
|
.header("Cross-Origin-Embedder-Policy", "require-corp")
|
|
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
|
}
|
|
|
|
// Add Content-Security-Policy header for static assets when policy is set
|
|
if !CSP_POLICY.is_empty() {
|
|
if let Ok(header_value) = HeaderValue::try_from(CSP_POLICY.as_str()) {
|
|
res = res.header("Content-Security-Policy", header_value);
|
|
}
|
|
}
|
|
if mime.as_ref() == mime::APPLICATION_JAVASCRIPT
|
|
|| mime.as_ref() == mime::TEXT_JAVASCRIPT
|
|
|| path.ends_with(".wasm")
|
|
{
|
|
res = res.header(header::CACHE_CONTROL, "max-age=31536000");
|
|
} else if (mime.type_(), mime.subtype()) == (mime::TEXT, mime::CSS) {
|
|
res = res.header(header::CACHE_CONTROL, "max-age=31536000");
|
|
} else if (mime.type_()) == (mime::IMAGE) || (mime.type_()) == (mime::FONT) {
|
|
res = res.header(header::CACHE_CONTROL, "max-age=31536000");
|
|
} else {
|
|
res = res.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate");
|
|
}
|
|
|
|
res.body(body).unwrap()
|
|
}
|
|
None if path.starts_with("_app/") => {
|
|
Response::builder().status(404).body(Body::empty()).unwrap()
|
|
}
|
|
None => serve_path(TWO_HUNDRED, original_path, query),
|
|
}
|
|
|
|
#[cfg(not(feature = "static_frontend"))]
|
|
{
|
|
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));
|
|
}
|
|
}
|