feat(appgate): proxied apps become embeddable — gate neutralizes frame blocking
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
76e6f06995
commit
a80712963c
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user