diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs new file mode 100644 index 0000000..50cdf84 --- /dev/null +++ b/server/tests/csrf_attack_test.rs @@ -0,0 +1,371 @@ +//! Attack-confirming tests for CSRF protection on /web/ state-changing +//! endpoints. +//! +//! Each test crafts a request that simulates a real attack shape +//! (cross-site POST with valid cookie, wrong token, empty token, etc.) +//! and asserts the expected defense. Specs marked `#[ignore]` are red +//! today and turn green once the `CsrfForm` extractor + session CSRF +//! token land. Remove the `#[ignore]` on each as it passes. +//! +//! Session-hygiene tests without `#[ignore]` (session fixation rotation) +//! document properties the server already has — they are regression +//! anchors. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use tower::util::ServiceExt; + +use doctate_server::config::{Config, User}; + +// ---------- fixtures ---------- + +fn make_user(slug: &str, password: &str, role: &str) -> User { + User { + slug: slug.into(), + api_key: format!("key-{slug}"), + web_password: bcrypt::hash(password, 4).unwrap(), + role: role.into(), + whisper: Default::default(), + retention: Default::default(), + window_hours: 72, + preview_lines: 2, + } +} + +fn test_config_with(users: Vec) -> Arc { + let data_path = std::env::temp_dir().join(format!( + "doctate-csrf-test-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let api_keys: HashMap = users + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); + Arc::new(Config { + data_path, + users, + api_keys, + ..Config::test_default() + }) +} + +fn extract_session_cookie(resp: &axum::response::Response) -> Option { + for v in resp.headers().get_all(header::SET_COOKIE).iter() { + let s = v.to_str().ok()?; + if let Some(pair) = s.split(';').next() + && pair.starts_with("session=") + { + return Some(pair.to_string()); + } + } + None +} + +fn login_request(slug: &str, password: &str) -> Request { + let body = format!("slug={slug}&password={password}"); + Request::builder() + .method("POST") + .uri("/web/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap() +} + +async fn login_as(app: &axum::Router, slug: &str, password: &str) -> String { + let resp = app + .clone() + .oneshot(login_request(slug, password)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16()); + extract_session_cookie(&resp).expect("no session cookie after login") +} + +fn form_post(path: &str, cookie: &str, body: &str) -> Request { + Request::builder() + .method("POST") + .uri(path) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from(body.to_owned())) + .unwrap() +} + +// ---------- session hygiene (green today) ---------- + +/// Session-fixation: attacker sets a known `session=...` cookie on the +/// victim's browser. If the server reused that value after login, the +/// attacker would be logged in as the victim. Defense: every successful +/// login mints a fresh token; the pre-set value must be overwritten. +#[tokio::test] +async fn session_fixation_login_rotates_cookie() { + let cfg = test_config_with(vec![make_user("dr_a", "secret", "doctor")]); + let app = doctate_server::create_router(cfg); + + let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa"; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/web/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, attacker_fixed_value) + .body(Body::from("slug=dr_a&password=secret")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16()); + let minted = extract_session_cookie(&resp).expect("no Set-Cookie on login"); + assert!( + minted != attacker_fixed_value, + "login did not rotate the cookie — session fixation possible" + ); + assert!( + minted.starts_with("session="), + "Set-Cookie shape unexpected: {minted}" + ); + // Token portion has the full 43-char entropy, not the attacker's value. + let minted_token = minted.trim_start_matches("session="); + assert!( + minted_token.len() >= 40, + "minted token looks truncated: {minted_token:?}" + ); +} + +// ---------- CSRF defense — missing token ---------- + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn bulk_without_csrf_token_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + let body = "action=close&case_id=00000000-0000-0000-0000-000000000000"; + let resp = app + .oneshot(form_post("/web/cases/bulk", &cookie, body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "bulk POST without csrf_token should be 403 — got {}", + resp.status() + ); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn purge_closed_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + let resp = app + .oneshot(form_post("/web/cases/purge-closed", &cookie, "confirm=yes")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn reset_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + let resp = app + .oneshot(form_post( + "/web/cases/11111111-1111-1111-1111-111111111111/reset", + &cookie, + "", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn close_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_a", "s").await; + + let resp = app + .oneshot(form_post( + "/web/cases/11111111-1111-1111-1111-111111111111/close", + &cookie, + "", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn reopen_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_a", "s").await; + + let resp = app + .oneshot(form_post( + "/web/cases/11111111-1111-1111-1111-111111111111/reopen", + &cookie, + "", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn analyze_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_a", "s").await; + + let resp = app + .oneshot(form_post( + "/web/cases/11111111-1111-1111-1111-111111111111/analyze", + &cookie, + "", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn delete_recording_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_a", "s").await; + + let resp = app + .oneshot(form_post( + "/web/cases/11111111-1111-1111-1111-111111111111/recordings/delete", + &cookie, + "filename=2026-04-14T10-00-00Z.m4a", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the +/// victim out of their active session (annoyance / phishing setup where +/// victim re-enters password on a lookalike page). +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn logout_without_csrf_forbidden() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_a", "s").await; + + let resp = app + .oneshot(form_post("/web/logout", &cookie, "")) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "forced-logout CSRF not blocked" + ); +} + +// ---------- CSRF defense — malformed token values ---------- + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn bulk_with_wrong_csrf_token_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + // A 43-char alphanumeric that is structurally valid but does not + // match the session's token. + let wrong = "a".repeat(43); + let body = + format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}"); + let resp = app + .oneshot(form_post("/web/cases/bulk", &cookie, &body)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn bulk_with_empty_csrf_token_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token="; + let resp = app + .oneshot(form_post("/web/cases/bulk", &cookie, body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "empty csrf_token must not bypass the check" + ); +} + +/// Whitespace trick: some frameworks trim tokens. If the server trimmed, +/// an attacker could send `csrf_token=%0A%0A` and hit an early-return +/// `if token.is_empty() { skip }` branch. Defense: compare raw. +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn bulk_with_whitespace_padded_token_forbidden() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + // Leading+trailing spaces around a value that *would* match post-trim. + // Even if the real token were "VALID", this should still fail. + let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20"; + let resp = app + .oneshot(form_post("/web/cases/bulk", &cookie, body)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +/// Paranoia: token-field value is a SQL-injection-looking string. The +/// server uses a HashMap, so SQL is a non-issue — this test just pins +/// down that the check returns a clean 403 rather than 500/panic on +/// unusual byte content. +#[tokio::test] +#[ignore = "TDD red spec — enable once CsrfForm extractor lands"] +async fn bulk_with_injection_payload_returns_403_not_500() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + let app = doctate_server::create_router(cfg); + let cookie = login_as(&app, "dr_admin", "s").await; + + // `' OR 1=1--` URL-encoded. + let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\ + &csrf_token=%27+OR+1%3D1--"; + let resp = app + .oneshot(form_post("/web/cases/bulk", &cookie, body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "malformed token must yield 403, not 500" + ); +} diff --git a/server/tests/security_headers_test.rs b/server/tests/security_headers_test.rs new file mode 100644 index 0000000..ae2af34 --- /dev/null +++ b/server/tests/security_headers_test.rs @@ -0,0 +1,327 @@ +//! 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" + ); +}