From f48fe95ac41e916a67bcc1482a9ce6450425452d Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Wed, 11 Mar 2026 17:55:38 -0700 Subject: [PATCH] fix(security): add Content-Security-Policy header to web gateway (#966) * fix(security): add Content-Security-Policy header to web gateway The web gateway set X-Frame-Options and X-Content-Type-Options but had no Content-Security-Policy header. Without CSP, there is no browser- enforced mitigation against XSS attacks. This adds a tailored CSP that matches the resources the frontend actually loads. Co-Authored-By: Claude Sonnet 4.6 * fix(security): address CSP review feedback - Remove cdnjs.cloudflare.com from script-src (not used in codebase) - Add explicit object-src 'none' per security best practice - Add regression test asserting CSP header presence and directives Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Gabe Hamilton Co-authored-by: Claude Sonnet 4.6 --- src/channels/web/server.rs | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 3beafc20..4dc58390 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -372,6 +372,21 @@ pub async fn start_server( header::X_FRAME_OPTIONS, header::HeaderValue::from_static("DENY"), )) + .layer(SetResponseHeaderLayer::if_not_present( + header::HeaderName::from_static("content-security-policy"), + header::HeaderValue::from_static( + "default-src 'self'; \ + script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; \ + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \ + font-src https://fonts.gstatic.com; \ + connect-src 'self'; \ + img-src 'self' data:; \ + object-src 'none'; \ + frame-ancestors 'none'; \ + base-uri 'self'; \ + form-action 'self'", + ), + )) .with_state(state.clone()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -2741,6 +2756,56 @@ mod tests { .with_state(state) } + #[tokio::test] + async fn test_csp_header_present_on_responses() { + use std::net::SocketAddr; + + let state = test_gateway_state(None); + + let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let bound = start_server(addr, state.clone(), "test-token".to_string()) + .await + .expect("server should start"); + + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://{}/api/health", bound)) + .send() + .await + .expect("health request should succeed"); + + assert_eq!(resp.status(), 200); + + let csp = resp + .headers() + .get("content-security-policy") + .expect("CSP header must be present"); + + let csp_str = csp.to_str().expect("CSP header should be valid UTF-8"); + assert!( + csp_str.contains("default-src 'self'"), + "CSP must contain default-src" + ); + assert!( + csp_str.contains( + "script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com" + ), + "CSP must allow both marked and DOMPurify script CDNs" + ); + assert!( + csp_str.contains("object-src 'none'"), + "CSP must contain object-src 'none'" + ); + assert!( + csp_str.contains("frame-ancestors 'none'"), + "CSP must contain frame-ancestors 'none'" + ); + + if let Some(tx) = state.shutdown_tx.write().await.take() { + let _ = tx.send(()); + } + } + #[tokio::test] async fn test_oauth_callback_missing_params() { use axum::body::Body;