From a80712963ccfdc07c9d1a1aaefc284db4242ba8d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 12 Aug 2026 13:05:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(appgate):=20proxied=20apps=20become=20embe?= =?UTF-8?q?ddable=20=E2=80=94=20gate=20neutralizes=20frame=20blocking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps that ship X-Frame-Options (Alby Hub: DENY) or a CSP frame-ancestors directive rendered as a dead grey pane in the dashboard's embedded app session; the historical fix was a bespoke per-app nginx strip proxy (gitea). The gate now removes X-Frame-Options and strips ONLY the frame-ancestors directive from proxied responses — the rest of the app's CSP passes through untouched. The clickjacking threat those headers address is handled the same way the gate's own pages handle it: every proxied request is authenticated first, and the gate already declares permissive frame-ancestors on its own responses. Unit-tested; verified live on archi-dev-box (Alby Hub embeds, CSP intact). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/mod.rs | 98 ++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 3df500d4..1baf0348 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -520,11 +520,63 @@ async fn proxy_to_app( let client = hyper::Client::new(); match client.request(Request::from_parts(parts, body)).await { - Ok(resp) => resp, + Ok(mut resp) => { + neutralize_frame_blocking(resp.headers_mut()); + resp + } Err(_) => app_down_page(app), } } +/// Make gate-proxied app responses embeddable by the dashboard's My Apps +/// iframe. Apps that were never designed for framing ship +/// `X-Frame-Options: DENY` (Alby Hub) or a CSP `frame-ancestors` directive, +/// and either one makes the embedded app session a dead grey pane — the +/// historical workaround was a bespoke per-app nginx proxy (gitea), which is +/// exactly the per-app patching the manifest platform exists to delete. +/// +/// Framing protection exists to stop a FOREIGN origin from framing an authed +/// page and clickjacking it. Behind the gate that threat model is already +/// handled the way the gate's own pages handle it: every proxied request is +/// authenticated by the gate first, and the gate's own responses declare +/// `frame-ancestors 'self' http://*:* https://*:*` (see `page()`) because the +/// dashboard is reached by LAN IP, mDNS name, and onion alike. Upstream +/// X-Frame-Options is dropped entirely; only the `frame-ancestors` directive +/// is removed from the app's CSP — the rest of the app's policy (script-src, +/// connect-src, …) is the app's business and passes through untouched. +fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) { + headers.remove("x-frame-options"); + let Some(csp) = headers.get("content-security-policy") else { + return; + }; + let Ok(raw) = csp.to_str() else { + return; + }; + if !raw.to_ascii_lowercase().contains("frame-ancestors") { + return; + } + let kept: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|d| !d.to_ascii_lowercase().starts_with("frame-ancestors") && !d.is_empty()) + .collect(); + if kept.is_empty() { + headers.remove("content-security-policy"); + return; + } + match header::HeaderValue::from_str(&kept.join("; ")) { + Ok(v) => { + headers.insert("content-security-policy", v); + } + Err(_) => { + // Unrepresentable after filtering — fail open for framing but + // closed for the policy: better to drop a mangled CSP than to + // serve one we rewrote incorrectly. + headers.remove("content-security-policy"); + } + } +} + /// Cookie names owned by the gate/daemon, never the app's to see. const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"]; @@ -1072,6 +1124,50 @@ mod tests { /// 2026-08-05). It must still be uncacheable, and still refuse to be /// framed by a foreign origin, which `frame-ancestors` expresses and /// `X-Frame-Options` cannot. + /// Upstream frame-blocking must not survive the proxy: X-Frame-Options + /// goes away entirely, CSP loses ONLY its frame-ancestors directive — + /// the app's remaining policy must pass through byte-preserving in + /// content (Alby Hub's DENY + strict CSP was the real-world case, + /// archi-dev-box 2026-08-12). + #[test] + fn proxied_responses_lose_frame_blocking_but_keep_the_apps_csp() { + let mut headers = hyper::HeaderMap::new(); + headers.insert("x-frame-options", "DENY".parse().unwrap()); + headers.insert( + "content-security-policy", + "default-src 'self'; frame-ancestors 'none'; img-src 'self' https://cdn.example" + .parse() + .unwrap(), + ); + neutralize_frame_blocking(&mut headers); + assert!(!headers.contains_key("x-frame-options")); + let csp = headers["content-security-policy"].to_str().unwrap(); + assert!(!csp.contains("frame-ancestors")); + assert!(csp.contains("default-src 'self'")); + assert!(csp.contains("img-src 'self' https://cdn.example")); + + // CSP that is ONLY a frame-ancestors directive disappears entirely. + let mut only = hyper::HeaderMap::new(); + only.insert( + "content-security-policy", + "frame-ancestors 'self'".parse().unwrap(), + ); + neutralize_frame_blocking(&mut only); + assert!(!only.contains_key("content-security-policy")); + + // No frame directives at all → CSP untouched. + let mut plain = hyper::HeaderMap::new(); + plain.insert( + "content-security-policy", + "default-src 'self'".parse().unwrap(), + ); + neutralize_frame_blocking(&mut plain); + assert_eq!( + plain["content-security-policy"].to_str().unwrap(), + "default-src 'self'" + ); + } + #[test] fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() { let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);