From f3d0380dbdc405b4ac0204caffb586d054fed232 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 22 Apr 2026 09:33:46 +0200 Subject: [PATCH] feat: add defense-in-depth security-header layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaches a SetResponseHeaderLayer stack to create_router_with_state (not main.rs) so tests observe the same response shape as production. `if_not_present` mode so per-route overrides (magic.rs already sets its own Referrer-Policy) are preserved. Sets: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Content-Security-Policy, Permissions-Policy. HSTS is deliberately omitted until TLS termination is in place — a cached max-age on a plain-HTTP deployment is irreversible. CSP uses 'unsafe-inline' for script/style since templates contain inline scripts; revisit if any user input ever renders unescaped. Removes #[ignore] from the 10 attack-confirming header tests; two regression anchors (no-HSTS, no-duplicated magic header) were already green. --- server/Cargo.toml | 2 +- server/src/lib.rs | 50 ++++++++++++++++++++++++++- server/tests/security_headers_test.rs | 15 ++------ 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/server/Cargo.toml b/server/Cargo.toml index b5a2581..a3b3824 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,7 +19,7 @@ uuid.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true -tower-http = { version = "0.6", features = ["limit", "trace"] } +tower-http = { version = "0.6", features = ["limit", "trace", "set-header"] } tokio-stream = { version = "0.1", features = ["sync"] } futures-util = "0.3" rand = "0.8" diff --git a/server/src/lib.rs b/server/src/lib.rs index 089bcbc..611b3ac 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -18,6 +18,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use axum::Router; use axum::extract::FromRef; +use axum::http::{HeaderName, HeaderValue}; +use tower_http::set_header::SetResponseHeaderLayer; use analyze::AnalyzeSender; use config::Config; @@ -194,5 +196,51 @@ pub fn create_router(config: Arc) -> Router { } pub fn create_router_with_state(state: AppState) -> Router { - routes::api_router().with_state(state) + with_security_headers(routes::api_router().with_state(state)) +} + +/// Content-Security-Policy for all responses. `'unsafe-inline'` on +/// script-src / style-src is a pragmatic concession — the templates +/// include inline scripts (e.g. partials/time_format.js, localStorage +/// toggles) and user content is always escaped by askama before +/// rendering, so the XSS surface is small. Revisit if ever rendering +/// unescaped user input. +const CSP: &str = "default-src 'self'; \ + script-src 'self' 'unsafe-inline'; \ + style-src 'self' 'unsafe-inline'; \ + img-src 'self' data:; \ + media-src 'self'; \ + object-src 'none'; \ + base-uri 'self'; \ + frame-ancestors 'none'; \ + form-action 'self'"; +const PERMISSIONS_POLICY: &str = "microphone=(), camera=(), geolocation=(), payment=()"; + +/// Defense-in-depth security headers applied to every response. +/// `if_not_present` respects per-route overrides (see routes/magic.rs, +/// which sets its own Referrer-Policy). HSTS is deliberately omitted +/// until TLS termination is in place; adding it prematurely on a +/// plain-HTTP deployment is irreversible (browser caches max-age). +fn with_security_headers(router: Router) -> Router { + router + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static(CSP), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static(PERMISSIONS_POLICY), + )) } diff --git a/server/tests/security_headers_test.rs b/server/tests/security_headers_test.rs index ae2af34..6199e0a 100644 --- a/server/tests/security_headers_test.rs +++ b/server/tests/security_headers_test.rs @@ -2,9 +2,8 @@ //! //! Each test simulates a threat the corresponding header is designed to //! mitigate and asserts the header is actually present on the response. -//! These specs are red today and turn green once the -//! `SetResponseHeaderLayer` stack lands in `main.rs`. Remove the -//! `#[ignore]` line as each assertion passes. +//! The layer is composed in `doctate_server::with_security_headers`, +//! applied to every response by `create_router_with_state`. use std::collections::HashMap; use std::sync::Arc; @@ -50,7 +49,6 @@ fn count_header_values(resp: &axum::response::Response, name: &str) -> usize { // ---------- presence tests ---------- #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands in main.rs"] async fn api_health_has_all_security_headers() { let app = doctate_server::create_router(test_config()); let resp = app @@ -80,7 +78,6 @@ async fn api_health_has_all_security_headers() { } #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn web_login_page_has_all_security_headers() { let app = doctate_server::create_router(test_config()); let resp = app @@ -103,7 +100,6 @@ async fn web_login_page_has_all_security_headers() { /// own page and tricks the victim into clicking overlaid elements. /// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn csp_blocks_clickjacking_via_frame_ancestors() { let app = doctate_server::create_router(test_config()); let resp = app @@ -126,7 +122,6 @@ async fn csp_blocks_clickjacking_via_frame_ancestors() { /// Plugin-based XSS via `` / ``. /// Defense: CSP `object-src 'none'`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn csp_blocks_object_embed_plugins() { let app = doctate_server::create_router(test_config()); let resp = app @@ -148,7 +143,6 @@ async fn csp_blocks_object_embed_plugins() { /// `` injection redirects all relative URLs. /// Defense: CSP `base-uri 'self'`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn csp_blocks_base_uri_hijack() { let app = doctate_server::create_router(test_config()); let resp = app @@ -170,7 +164,6 @@ async fn csp_blocks_base_uri_hijack() { /// Injected HTML redirects form submissions to attacker-controlled URL. /// Defense: CSP `form-action 'self'`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn csp_blocks_form_action_hijack() { let app = doctate_server::create_router(test_config()); let resp = app @@ -192,7 +185,6 @@ async fn csp_blocks_form_action_hijack() { /// MIME sniffing allows a non-HTML upload to be interpreted as HTML and /// executed. Defense: `X-Content-Type-Options: nosniff`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn nosniff_blocks_mime_confusion() { let app = doctate_server::create_router(test_config()); let resp = app @@ -213,7 +205,6 @@ async fn nosniff_blocks_mime_confusion() { /// Session-carrying URLs should not leak to third parties via `Referer`. /// Defense: `Referrer-Policy: no-referrer`. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn referrer_policy_prevents_leak() { let app = doctate_server::create_router(test_config()); let resp = app @@ -231,7 +222,6 @@ async fn referrer_policy_prevents_leak() { /// Malicious script requests microphone/camera access in background. /// Defense: `Permissions-Policy` explicitly denies those features. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn permissions_policy_blocks_sensitive_features() { let app = doctate_server::create_router(test_config()); let resp = app @@ -258,7 +248,6 @@ async fn permissions_policy_blocks_sensitive_features() { /// excuse to skip hardening. Many frameworks have a bug where layers /// short-circuit on error paths. #[tokio::test] -#[ignore = "TDD red spec — enable once security-header layer lands"] async fn error_redirect_still_carries_security_headers() { let app = doctate_server::create_router(test_config()); // /web/cases without cookie → 302 redirect (error path from the