From 9c29f18f0f5a3efb72dbb4054a33d187129c888a Mon Sep 17 00:00:00 2001 From: simianastronaut Date: Thu, 12 Mar 2026 12:01:37 -0400 Subject: [PATCH] fix(gateway): add security headers to all HTTP responses (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add defense-in-depth security headers via axum middleware layer applied to every gateway response, closing CWE-352 gap identified in issue #8: - X-Content-Type-Options: nosniff (prevents MIME-type sniffing) - X-Frame-Options: DENY (prevents clickjacking) - Cache-Control: no-store (prevents caching of sensitive API responses) - Content-Security-Policy: default-src 'none' (restrictive CSP) Implemented as an axum map_response middleware so all routes — webhooks, API, health, metrics, SSE, WebSocket upgrade, and static assets — get the headers without per-handler changes. Closes #8 Co-Authored-By: Claude Opus 4.6 --- src/gateway/mod.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bdc472485..7e38f4a3b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -29,8 +29,9 @@ use anyhow::{Context, Result}; use axum::{ body::Bytes, extract::{ConnectInfo, Query, State}, - http::{header, HeaderMap, StatusCode}, - response::{IntoResponse, Json}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + middleware, + response::{IntoResponse, Json, Response}, routing::{delete, get, post, put}, Router, }; @@ -710,6 +711,8 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { StatusCode::REQUEST_TIMEOUT, Duration::from_secs(REQUEST_TIMEOUT_SECS), )) + // ── Security headers on every response (CWE-352 / defense-in-depth) ── + .layer(middleware::map_response(security_headers)) // ── SPA fallback: non-API GET requests serve index.html ── .fallback(get(static_files::handle_spa_fallback)); @@ -727,6 +730,33 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { Ok(()) } +// ══════════════════════════════════════════════════════════════════════════════ +// SECURITY HEADERS MIDDLEWARE +// ══════════════════════════════════════════════════════════════════════════════ + +/// Middleware that injects security headers into every HTTP response. +/// +/// Headers added: +/// - `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing +/// - `X-Frame-Options: DENY` — prevents clickjacking via iframes +/// - `Cache-Control: no-store` — prevents caching of API responses +/// - `Content-Security-Policy: default-src 'none'` — restrictive CSP for API responses +async fn security_headers(response: Response) -> Response { + let mut response = response; + let headers = response.headers_mut(); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'"), + ); + response +} + // ══════════════════════════════════════════════════════════════════════════════ // AXUM HANDLERS // ══════════════════════════════════════════════════════════════════════════════ @@ -1667,6 +1697,43 @@ mod tests { assert_eq!(REQUEST_TIMEOUT_SECS, 30); } + #[tokio::test] + async fn security_headers_are_injected() { + let inner = axum::http::Response::builder() + .status(200) + .body(axum::body::Body::from("ok")) + .unwrap(); + let response = security_headers(inner).await; + assert_eq!( + response + .headers() + .get(header::X_CONTENT_TYPE_OPTIONS) + .map(|v| v.to_str().unwrap()), + Some("nosniff"), + ); + assert_eq!( + response + .headers() + .get(header::X_FRAME_OPTIONS) + .map(|v| v.to_str().unwrap()), + Some("DENY"), + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .map(|v| v.to_str().unwrap()), + Some("no-store"), + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .map(|v| v.to_str().unwrap()), + Some("default-src 'none'"), + ); + } + #[test] fn webhook_body_requires_message_field() { let valid = r#"{"message": "hello"}"#;