//! 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. The defense is implemented by //! the `CsrfForm` extractor in `server/src/csrf.rs`, validated //! against `WebSession::csrf_token`. 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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" ); } // ---------- template rendering: the token must reach the browser ---------- // // The server-side CSRF check is useless if the templates forget to emit // the hidden field — the legitimate user would get 403 on every button. // These tests GET each authenticated page, grab the session's real // csrf_token from the store, and assert it appears verbatim in a // hidden input in the rendered HTML. If anyone adds a new state- // changing form without the {% call csrf::field(csrf_token) %} macro, // the browser flow breaks and one of these fires. async fn login_and_get_csrf( app: &axum::Router, store: &doctate_server::web_session::SessionStore, slug: &str, password: &str, ) -> (String, String) { let resp = app .clone() .oneshot(login_request(slug, password)) .await .unwrap(); let cookie = extract_session_cookie(&resp).expect("login cookie"); let csrf = store .read() .await .get(cookie.trim_start_matches("session=")) .unwrap() .csrf_token .clone(); (cookie, csrf) } async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String { let resp = app .clone() .oneshot( Request::builder() .uri(uri) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed"); let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } fn hidden_field(token: &str) -> String { format!(r#"name="csrf_token" value="{token}""#) } #[tokio::test] async fn my_cases_page_renders_csrf_token_hidden_field() { let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); // Seed a case so the bulk form is rendered (the template skips it // when the list is empty) — otherwise only the logout form shows. let case_dir = cfg .data_path .join("dr_admin/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_admin", "s").await; let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await; let expected = hidden_field(&csrf); // Logout form must carry the token. assert!( body.contains(&expected), "/web/cases should render hidden csrf_token ({expected}), \ first 200 chars of body: {}", &body[..body.len().min(200)] ); // Logout form must specifically have it (present in the body). assert!( body.contains(r#"action="/web/logout""#) && body.matches(r#"name="csrf_token""#).count() >= 2, "expected at least 2 csrf_token hidden fields (logout + bulk); \ got {} occurrences", body.matches(r#"name="csrf_token""#).count() ); } #[tokio::test] async fn case_page_renders_csrf_token_hidden_field() { let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); let case_id = "11111111-1111-1111-1111-111111111111"; // Seed a minimal case so GET /web/cases/{id} returns 200 instead of 404. let case_dir = cfg.data_path.join("dr_a").join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await; let expected = hidden_field(&csrf); assert!( body.contains(&expected), "case page must render hidden csrf_token for close/analyze forms" ); // Close form plus logout form → at least 2 hidden fields. assert!( body.matches(r#"name="csrf_token""#).count() >= 2, "case page should have ≥2 csrf_token inputs; got {}", body.matches(r#"name="csrf_token""#).count() ); } #[tokio::test] async fn case_recordings_page_renders_csrf_token_hidden_field() { let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = cfg.data_path.join("dr_a").join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); std::fs::write(case_dir.join("2026-04-14T10-00-00Z.transcript.txt"), b"hi").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; let body = html_body(&app, &format!("/web/cases/{case_id}/recordings"), &cookie).await; let expected = hidden_field(&csrf); assert!( body.contains(&expected), "recordings page must render hidden csrf_token for the delete form" ); // Logout form + one delete form per recording (one here) ≥ 2. assert!( body.matches(r#"name="csrf_token""#).count() >= 2, "recordings page should have ≥2 csrf_token inputs; got {}", body.matches(r#"name="csrf_token""#).count() ); } /// Full round-trip: fetch the page, extract the csrf_token from the /// rendered HTML, POST with that token — expect 303 (success). This is /// the legitimate-user path; if the server-side check or the template /// ever drift out of sync, this test catches it. Protects against the /// subtle regression where the token is rendered but with a value the /// server won't accept (e.g. accidental trim, base64-encoded, etc.). #[tokio::test] async fn happy_path_post_with_rendered_token_accepted() { let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = cfg.data_path.join("dr_a").join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, session_csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; // Render the case page, parse the csrf_token value out of any // hidden input — do NOT use the session store directly; this tests // the *rendered* token specifically. let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await; let needle = r#"name="csrf_token" value=""#; let start = body.find(needle).expect("no csrf_token in HTML") + needle.len(); let end = body[start..] .find('"') .expect("unterminated csrf_token value") + start; let rendered = &body[start..end]; // Rendered token must be the session's actual token. If these ever // diverge we want the test to fail loudly. assert_eq!( rendered, session_csrf, "rendered csrf_token must match session's csrf_token" ); // POST close with the rendered token → must succeed. let resp = app .oneshot(form_post( &format!("/web/cases/{case_id}/close"), &cookie, &format!("csrf_token={rendered}"), )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::SEE_OTHER, "legitimate POST with rendered token must be accepted (303)" ); }