//! 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" ); }