From 81653b1dcf8c09ff6e200a78a0e7863937a05b0a Mon Sep 17 00:00:00 2001 From: kelly-musk Date: Mon, 30 Mar 2026 11:29:36 +0100 Subject: [PATCH] fix: mount security headers middleware globally (#373) - Layer security_headers_middleware on the top-level app router so all responses include CSP, HSTS, X-Frame-Options, Referrer-Policy, and related headers. - Import TrustProxy in lib.rs (needed by the security module). - Add unit test that asserts the required headers are present on every response. --- services/api/src/lib.rs | 5 +++-- services/api/src/security.rs | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/services/api/src/lib.rs b/services/api/src/lib.rs index 9537f9e..6c4c55e 100644 --- a/services/api/src/lib.rs +++ b/services/api/src/lib.rs @@ -29,7 +29,7 @@ use db::Database; use email::{queue::EmailQueue, service::EmailService, webhook::WebhookHandler}; use metrics::Metrics; use newsletter::IpRateLimiter; -use security::{ApiKeyAuth, IpWhitelist, RateLimiter}; +use security::{ApiKeyAuth, IpWhitelist, RateLimiter, TrustProxy}; use shutdown::ShutdownCoordinator; use tokio::net::TcpListener; use tower_http::{ @@ -247,7 +247,8 @@ pub async fn run() -> anyhow::Result<()> { .merge(newsletter_routes) .merge(admin_routes) .merge(webhook_routes) - .layer(cors); + .layer(cors) + .layer(middleware::from_fn(security::security_headers_middleware)); let listener = TcpListener::bind(bind_addr).await?; tracing::info!("API listening on {bind_addr}"); diff --git a/services/api/src/security.rs b/services/api/src/security.rs index 477d9ae..4a4053e 100644 --- a/services/api/src/security.rs +++ b/services/api/src/security.rs @@ -465,7 +465,30 @@ mod tests { h } - // ── existing behaviour (trust_proxy = true) ─────────────────────────── + // ── security headers middleware ─────────────────────────────────────── + + #[tokio::test] + async fn security_headers_middleware_sets_required_headers() { + use axum::{body::Body, http::Request, middleware, routing::get, Router}; + use tower::ServiceExt; + + let app = Router::new() + .route("/", get(|| async { "ok" })) + .layer(middleware::from_fn(super::security_headers_middleware)); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let headers = response.headers(); + assert!(headers.contains_key("content-security-policy")); + assert!(headers.contains_key("strict-transport-security")); + assert!(headers.contains_key("x-frame-options")); + assert!(headers.contains_key("referrer-policy")); + assert_eq!(headers["x-frame-options"], "DENY"); + assert_eq!(headers["x-content-type-options"], "nosniff"); + } #[test] fn test_extract_client_ip_precedence() {