feat: add defense-in-depth security-header layer

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.
This commit is contained in:
2026-04-22 09:33:46 +02:00
parent 34aa633d32
commit f3d0380dbd
3 changed files with 52 additions and 15 deletions
+2 -13
View File
@@ -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 `<object>` / `<embed>`.
/// 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() {
/// `<base href="https://evil/">` 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