//! Attack-confirming tests for the global security-header layer. //! //! 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. use std::collections::HashMap; use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; // ---------- fixtures ---------- fn test_config() -> Arc { Arc::new(Config { data_path: std::env::temp_dir().join(format!( "doctate-sechdr-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )), users: vec![User { slug: "dr_test".into(), api_key: "test-key-123".into(), web_password: bcrypt::hash("secret", 4).unwrap(), role: "doctor".into(), whisper: Default::default(), retention: Default::default(), window_hours: 72, preview_lines: 2, }], api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), ..Config::test_default() }) } fn header_value<'a>(resp: &'a axum::response::Response, name: &str) -> Option<&'a str> { resp.headers().get(name).and_then(|v| v.to_str().ok()) } fn count_header_values(resp: &axum::response::Response, name: &str) -> usize { resp.headers().get_all(name).iter().count() } // ---------- 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( header_value(&resp, "x-content-type-options"), Some("nosniff") ); assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); assert!( header_value(&resp, "content-security-policy").is_some(), "CSP header missing on /api/health" ); assert!( header_value(&resp, "permissions-policy").is_some(), "Permissions-Policy missing on /api/health" ); } #[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 .oneshot( Request::builder() .uri("/web/login") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); assert!(header_value(&resp, "content-security-policy").is_some()); } // ---------- per-attack tests ---------- /// Clickjacking: attacker embeds /web/cases in a hidden iframe on their /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); let csp = header_value(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("frame-ancestors 'none'"), "CSP missing frame-ancestors: {csp}" ); } /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let csp = header_value(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("object-src 'none'"), "CSP missing object-src 'none': {csp}" ); } /// `` 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let csp = header_value(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("base-uri 'self'"), "CSP missing base-uri 'self': {csp}" ); } /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let csp = header_value(&resp, "content-security-policy").unwrap_or(""); assert!( csp.contains("form-action 'self'"), "CSP missing form-action 'self': {csp}" ); } /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( header_value(&resp, "x-content-type-options"), Some("nosniff") ); } /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); } /// 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 .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let pp = header_value(&resp, "permissions-policy").unwrap_or(""); for feat in ["microphone", "camera", "geolocation", "payment"] { assert!( pp.contains(&format!("{feat}=()")), "Permissions-Policy missing '{feat}=()': {pp}" ); } } // ---------- edge / regression tests ---------- /// Headers must apply to error responses too — a 302 or 404 is not an /// 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 // session extractor). let resp = app .oneshot( Request::builder() .uri("/web/cases") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( header_value(&resp, "x-content-type-options"), Some("nosniff") ); assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY")); assert!(header_value(&resp, "content-security-policy").is_some()); } /// `magic.rs` already sets `Referrer-Policy: no-referrer` on the magic /// route. The global layer must use `if_not_present` so the header /// appears exactly once, not doubled. Passes today (no global layer /// yet) and must keep passing after the layer lands. #[tokio::test] async fn magic_route_referrer_policy_not_duplicated() { let app = doctate_server::create_router(test_config()); let resp = app .oneshot( Request::builder() .uri("/web/magic?token=definitely-invalid") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( count_header_values(&resp, "referrer-policy"), 1, "Referrer-Policy appeared more than once — header layer should be if_not_present" ); assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer")); } /// HSTS must NOT be set until TLS is actually terminated in front of the /// server — otherwise legitimate HTTP dev deployments break irreversibly /// (max-age cache). Regression guard against accidental HSTS additions. #[tokio::test] async fn no_hsts_header_until_tls_is_in_place() { let app = doctate_server::create_router(test_config()); let resp = app .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert!( resp.headers().get("strict-transport-security").is_none(), "HSTS set but no TLS termination is in place — would break HTTP deployments" ); }