Files
doctate/server/tests/security_headers_test.rs
T
Brummel f3d0380dbd 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.
2026-04-22 09:33:46 +02:00

317 lines
10 KiB
Rust

//! 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.
//! 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;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
// ---------- fixtures ----------
fn test_config() -> Arc<Config> {
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]
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]
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]
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 `<object>` / `<embed>`.
/// Defense: CSP `object-src 'none'`.
#[tokio::test]
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}"
);
}
/// `<base href="https://evil/">` injection redirects all relative URLs.
/// Defense: CSP `base-uri 'self'`.
#[tokio::test]
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]
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]
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]
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]
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]
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"
);
}