diff --git a/Cargo.lock b/Cargo.lock index 157fe17..5951cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1219,6 +1219,7 @@ dependencies = [ "serde_json", "spellbook", "strsim", + "subtle", "tempfile", "time", "tokio", diff --git a/server/Cargo.toml b/server/Cargo.toml index a3b3824..522bb16 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,6 +31,7 @@ rpassword = "7.4.0" pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } strsim = "0.11" spellbook = "0.4.0" +subtle = "2" [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/server/src/csrf.rs b/server/src/csrf.rs new file mode 100644 index 0000000..c997a82 --- /dev/null +++ b/server/src/csrf.rs @@ -0,0 +1,114 @@ +//! CSRF-token validation for state-changing POSTs under `/web/`. +//! +//! The `WebSession` carries a `csrf_token` minted once at login or +//! magic-link consume. Browser forms include a hidden `csrf_token` +//! field populated from the session. Handlers that mutate server +//! state use [`CsrfForm`] instead of `Form` to enforce that the +//! submitted token matches the session's token. The session cookie +//! alone is not sufficient: `SameSite=Strict` already blocks most +//! cross-site POSTs, but the token is the defence that also holds +//! against attackers with cross-site injection on a same-site +//! subdomain or misconfigured browser. + +use std::sync::Arc; +use std::time::Instant; + +use axum::extract::{FromRef, FromRequest, Request}; +use axum_extra::extract::{CookieJar, Form}; +use serde::de::DeserializeOwned; +use subtle::ConstantTimeEq; +use tracing::warn; + +use crate::config::Config; +use crate::error::AppError; +use crate::web_session::{SESSION_COOKIE, SessionStore}; + +/// Every form struct protected by CSRF implements this trait so the +/// extractor can pull the token out of the decoded body. A plain +/// `#[serde(default)] pub csrf_token: String` plus a three-line impl +/// of this trait is all the boilerplate each form needs. +pub trait HasCsrfToken { + fn csrf_token(&self) -> &str; +} + +/// Wrapping extractor — a drop-in replacement for `Form` on +/// state-changing `/web/` POST handlers. Validates the form body's +/// `csrf_token` against the session's csrf_token via a constant-time +/// comparison. +/// +/// Rejection mapping: +/// - Missing or expired session → 302 redirect to `/web/login` (so a +/// second-tab logout shows "bitte neu anmelden", not "csrf failed"). +/// - Form body malformed → 400 `BadRequest`. +/// - Token present but mismatched → 403 `Forbidden`. +pub struct CsrfForm(pub T); + +impl FromRequest for CsrfForm +where + S: Send + Sync, + T: DeserializeOwned + HasCsrfToken + Send + 'static, + SessionStore: FromRef, + Arc: FromRef, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let store = SessionStore::from_ref(state); + + // Look up the expected token first (headers only — the body + // extractor below will consume `req`). + let cookie_token = CookieJar::from_headers(req.headers()) + .get(SESSION_COOKIE) + .map(|c| c.value().to_owned()) + .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + + let expected = { + let r = store.read().await; + let session = r + .get(&cookie_token) + .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + if session.expires_at <= Instant::now() { + return Err(AppError::Redirect("/web/login".into())); + } + session.csrf_token.clone() + }; + + // Consume the body. `axum_extra::extract::Form` supports + // repeated fields (BulkForm's `Vec`); plain + // `axum::extract::Form` does not. + let Form(form): Form = Form::from_request(req, state) + .await + .map_err(|e| AppError::BadRequest(format!("form parse: {e}")))?; + + // Constant-time compare. `ConstantTimeEq` on byte slices + // returns `Choice(0)` when the lengths differ, so empty or + // malformed tokens fall through here without a special case. + let provided = form.csrf_token(); + if !bool::from(provided.as_bytes().ct_eq(expected.as_bytes())) { + warn!( + provided_len = provided.len(), + expected_len = expected.len(), + "csrf token mismatch" + ); + return Err(AppError::Forbidden("CSRF-Token ungültig oder fehlt".into())); + } + + Ok(CsrfForm(form)) + } +} + +/// Form body with only a CSRF token — for handlers that are +/// state-changing but carry no other user-submitted data +/// (close/reopen/reset/analyze/logout). Using one shared type beats +/// five wordless copy-paste structs. +#[derive(serde::Deserialize)] +pub struct CsrfOnlyForm { + #[serde(default)] + pub csrf_token: String, +} + +impl HasCsrfToken for CsrfOnlyForm { + fn csrf_token(&self) -> &str { + &self.csrf_token + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 611b3ac..99a654e 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod analyze; pub mod auth; pub mod case_id; pub mod config; +pub mod csrf; pub mod error; pub mod events; pub mod gazetteer; @@ -178,13 +179,23 @@ impl FromRef for Arc { /// because the receivers are not retained. Use [`create_router_with_state`] /// from `main.rs` where real workers own the receivers. pub fn create_router(config: Arc) -> Router { + create_router_and_session_store(config).0 +} + +/// Same as [`create_router`], but also returns a handle to the newly +/// created session store so tests can peek at `WebSession::csrf_token` +/// values without rendering and parsing HTML. The store handle is +/// shared with the router (`Arc`), so any session created via `/web/login` +/// or `/web/magic` becomes observable through it. +pub fn create_router_and_session_store(config: Arc) -> (Router, SessionStore) { let (transcribe_tx, _transcribe_rx) = transcribe::channel(); let (analyze_tx, _analyze_rx) = analyze::channel(); - create_router_with_state(AppState { + let session_store = web_session::new_store(); + let router = create_router_with_state(AppState { config, transcribe_tx, analyze_tx, - session_store: web_session::new_store(), + session_store: session_store.clone(), magic_link_store: magic_link::new_store(), analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), @@ -192,7 +203,8 @@ pub fn create_router(config: Arc) -> Router { events_tx: events::channel(), http_client: reqwest::Client::new(), vocab: Arc::new(Gazetteer::empty()), - }) + }); + (router, session_store) } pub fn create_router_with_state(state: AppState) -> Router { diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index 0b28f9f..6b1db4f 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use axum::extract::State; use axum::response::Redirect; -use axum_extra::extract::Form; use doctate_common::timestamp::now_rfc3339; use serde::Deserialize; use tracing::{info, warn}; @@ -10,6 +9,7 @@ use tracing::{info, warn}; use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; +use crate::csrf::{CsrfForm, HasCsrfToken}; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; use crate::paths::{CloseMarker, write_close_marker}; @@ -25,6 +25,14 @@ pub struct BulkForm { /// (handled as a no-op). #[serde(default, rename = "case_id")] case_ids: Vec, + #[serde(default)] + csrf_token: String, +} + +impl HasCsrfToken for BulkForm { + fn csrf_token(&self) -> &str { + &self.csrf_token + } } /// POST /web/cases/bulk — apply `action` (analyze | close | reset) to @@ -35,7 +43,7 @@ pub async fn handle_bulk_action( State(config): State>, State(analyze_tx): State, State(events_tx): State, - Form(form): Form, + CsrfForm(form): CsrfForm, ) -> Result { // Bulk actions are admin-only across the board: the UI hides the // selection checkboxes and action bar from non-admins, and this diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 78c08e2..b02612a 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; -use axum::extract::{Form, State}; +use axum::extract::State; use axum::http::HeaderMap; use axum::response::Redirect; use serde::Deserialize; @@ -21,6 +21,7 @@ use crate::analyze::{ use crate::auth::AuthenticatedWebUser; use crate::case_id::CaseIdPath; use crate::config::Config; +use crate::csrf::{CsrfForm, CsrfOnlyForm, HasCsrfToken}; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; use crate::paths::{ @@ -43,6 +44,7 @@ pub async fn handle_analyze_case( State(events_tx): State, CaseIdPath(case_id): CaseIdPath, headers: HeaderMap, + _csrf: CsrfForm, ) -> Result { if !config.llm_configured() { return Err(AppError::ServiceUnavailable( @@ -323,6 +325,7 @@ pub async fn handle_close_case( State(events_tx): State, CaseIdPath(case_id): CaseIdPath, headers: HeaderMap, + _csrf: CsrfForm, ) -> Result { let user_root = config.data_path.join(&user.slug); let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?; @@ -365,6 +368,7 @@ pub async fn handle_reopen_case( State(events_tx): State, CaseIdPath(case_id): CaseIdPath, headers: HeaderMap, + _csrf: CsrfForm, ) -> Result { let user_root = config.data_path.join(&user.slug); let case_dir = user_root.join(case_id.to_string()); @@ -414,6 +418,7 @@ pub async fn handle_reset_case( State(events_tx): State, CaseIdPath(case_id): CaseIdPath, headers: HeaderMap, + _csrf: CsrfForm, ) -> Result { if !user.is_admin() { return Err(AppError::Forbidden("Nur für Admins".into())); @@ -442,6 +447,14 @@ pub struct PurgeClosedForm { // rather than a 422 from axum's form extractor. #[serde(default)] pub confirm: String, + #[serde(default)] + pub csrf_token: String, +} + +impl HasCsrfToken for PurgeClosedForm { + fn csrf_token(&self) -> &str { + &self.csrf_token + } } /// POST /web/cases/purge-closed @@ -458,7 +471,7 @@ pub async fn handle_purge_closed( user: AuthenticatedWebUser, State(config): State>, State(events_tx): State, - Form(form): Form, + CsrfForm(form): CsrfForm, ) -> Result { // Destructive, irreversible — admin-only. UI hides the button for // non-admins; this is the server-side defense-in-depth gate. @@ -517,6 +530,14 @@ pub async fn handle_purge_closed( #[derive(Deserialize)] pub struct DeleteRecordingForm { pub filename: String, + #[serde(default)] + pub csrf_token: String, +} + +impl HasCsrfToken for DeleteRecordingForm { + fn csrf_token(&self) -> &str { + &self.csrf_token + } } /// POST /web/cases/{case_id}/recordings/delete @@ -538,7 +559,7 @@ pub async fn handle_delete_recording( State(events_tx): State, CaseIdPath(case_id): CaseIdPath, headers: HeaderMap, - Form(form): Form, + CsrfForm(form): CsrfForm, ) -> Result { validate_filename(&form.filename)?; diff --git a/server/src/routes/login.rs b/server/src/routes/login.rs index 9e82b36..4f97884 100644 --- a/server/src/routes/login.rs +++ b/server/src/routes/login.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use tracing::{info, warn}; use crate::config::Config; +use crate::csrf::{CsrfForm, CsrfOnlyForm}; use crate::error::AppError; use crate::web_session::{ SESSION_COOKIE, SessionStore, WebSession, build_session_cookie, generate_token, @@ -57,6 +58,7 @@ pub async fn handle_login_submit( let user = user.unwrap(); let token = generate_token(); + let csrf_token = generate_token(); let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600); { @@ -67,6 +69,7 @@ pub async fn handle_login_submit( slug: user.slug.clone(), role: user.role.clone(), expires_at: Instant::now() + ttl, + csrf_token, }, ); } @@ -82,6 +85,7 @@ pub async fn handle_logout( State(config): State>, State(store): State, jar: CookieJar, + _csrf: CsrfForm, ) -> Result { if let Some(c) = jar.get(SESSION_COOKIE) { let token = c.value().to_owned(); diff --git a/server/src/routes/magic.rs b/server/src/routes/magic.rs index 069d4fe..42dd78e 100644 --- a/server/src/routes/magic.rs +++ b/server/src/routes/magic.rs @@ -119,6 +119,7 @@ pub async fn handle_consume( } let session_token = generate_token(); + let csrf_token = generate_token(); let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600); { let mut w = session_store.write().await; @@ -128,6 +129,7 @@ pub async fn handle_consume( slug: pending.slug.clone(), role: pending.role.clone(), expires_at: Instant::now() + ttl, + csrf_token, }, ); } diff --git a/server/src/web_session.rs b/server/src/web_session.rs index 9c2e1b9..228ff30 100644 --- a/server/src/web_session.rs +++ b/server/src/web_session.rs @@ -11,6 +11,11 @@ pub struct WebSession { pub slug: String, pub role: String, pub expires_at: Instant, + /// CSRF token — bound to the session for its full lifetime. Minted + /// alongside the session cookie via [`generate_token`] and validated + /// by the `CsrfForm` extractor on every state-changing POST under + /// `/web/`. Not rotated per request (would break multi-tab use). + pub csrf_token: String, } pub type SessionStore = Arc>>; diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 9a577d7..d36dae9 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -92,7 +92,11 @@ fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) { } } -async fn login(app: axum::Router, slug: &str) -> String { +async fn login( + app: axum::Router, + store: &doctate_server::web_session::SessionStore, + slug: &str, +) -> (String, String) { let body = format!("slug={slug}&password=s"); let resp = app .oneshot( @@ -110,41 +114,56 @@ async fn login(app: axum::Router, slug: &str) -> String { if let Some(pair) = s.split(';').next() && pair.starts_with("session=") { - return pair.to_string(); + let cookie = pair.to_string(); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .expect("session must be in store after login") + .csrf_token + .clone(); + return (cookie, csrf); } } panic!("no session cookie after login"); } -fn analyze_request(case_id: &str, cookie: &str) -> Request { +fn analyze_request(case_id: &str, cookie: &str, csrf: &str) -> Request { Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/analyze")) .header(header::COOKIE, cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap() } -fn close_request(case_id: &str, cookie: &str) -> Request { +fn close_request(case_id: &str, cookie: &str, csrf: &str) -> Request { Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/close")) .header(header::COOKIE, cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap() } -fn reopen_request(case_id: &str, cookie: &str) -> Request { +fn reopen_request(case_id: &str, cookie: &str, csrf: &str) -> Request { Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/reopen")) .header(header::COOKIE, cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap() } -fn purge_closed_request(cookie: &str, confirm: Option<&str>) -> Request { - let body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default(); +fn purge_closed_request(cookie: &str, csrf: &str, confirm: Option<&str>) -> Request { + let mut body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default(); + if !body.is_empty() { + body.push('&'); + } + body.push_str(&format!("csrf_token={csrf}")); Request::builder() .method("POST") .uri("/web/cases/purge-closed") @@ -185,11 +204,11 @@ async fn analyze_foreign_case_returns_404() { std::fs::create_dir_all(&foreign_dir).unwrap(); seed_recording(&foreign_dir, "10-00-00", Some("text")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(foreign_case, &cookie)) + .oneshot(analyze_request(foreign_case, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -198,11 +217,11 @@ async fn analyze_foreign_case_returns_404() { #[tokio::test] async fn analyze_invalid_uuid_returns_400() { let config = config_with_llm(unique_tmp("c"), "http://unused".into()); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request("not-a-uuid", &cookie)) + .oneshot(analyze_request("not-a-uuid", &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -215,11 +234,11 @@ async fn analyze_case_without_recordings_returns_400() { seed_case(&config.data_path, "dr_a", case_id); // No .m4a files. - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -233,11 +252,11 @@ async fn analyze_case_with_missing_transcript_returns_400() { seed_recording(&case_dir, "10-00-00", Some("ok")); seed_recording(&case_dir, "10-05-00", None); // still transcribing - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -250,11 +269,11 @@ async fn analyze_case_without_llm_returns_503() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); @@ -276,11 +295,11 @@ async fn analyze_case_happy_path_writes_input_and_redirects() { Some("Korrektur: links, nicht rechts."), ); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -313,18 +332,18 @@ async fn analyze_case_second_time_returns_409() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("text")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let first = app .clone() - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(first.status(), StatusCode::SEE_OTHER); let second = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); @@ -339,11 +358,11 @@ async fn analyze_case_skips_blank_transcript_from_recordings() { seed_recording(&case_dir, "10-05-00", Some(" ")); // blank seed_recording(&case_dir, "10-10-00", Some("auch echt")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -607,11 +626,11 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme")); seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app - .oneshot(analyze_request(case_id, &cookie)) + .oneshot(analyze_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -638,10 +657,13 @@ async fn close_writes_marker_and_hides_case() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(close_request(case_id, &cookie, &csrf)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() @@ -668,12 +690,12 @@ async fn closed_case_returns_404_on_detail() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -698,20 +720,20 @@ async fn reopen_removes_marker_and_restores_case() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; // Close, then reopen via the explicit endpoint. let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); assert!(case_dir.join(".closed").exists()); let resp = app .clone() - .oneshot(reopen_request(case_id, &cookie)) + .oneshot(reopen_request(case_id, &cookie, &csrf)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -741,13 +763,13 @@ async fn show_closed_query_includes_closed_cases() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("a closed one")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; // Close first. let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); @@ -814,12 +836,12 @@ async fn closed_case_detail_with_show_closed_returns_200() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("x")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); @@ -862,8 +884,8 @@ async fn close_redirects_back_to_referer_query() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app .oneshot( @@ -872,7 +894,8 @@ async fn close_redirects_back_to_referer_query() { .uri(format!("/web/cases/{case_id}/close")) .header(header::COOKIE, &cookie) .header(header::REFERER, "/web/cases?show_closed=1") - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -898,8 +921,8 @@ async fn close_from_detail_redirects_to_list_preserving_query() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let resp = app .oneshot( @@ -911,7 +934,8 @@ async fn close_from_detail_redirects_to_list_preserving_query() { header::REFERER, format!("/web/cases/{case_id}?show_closed=1"), ) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -938,12 +962,12 @@ async fn reopen_redirects_back_to_referer() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); @@ -954,7 +978,8 @@ async fn reopen_redirects_back_to_referer() { .uri(format!("/web/cases/{case_id}/reopen")) .header(header::COOKIE, &cookie) .header(header::REFERER, "/web/cases?show_closed=1") - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -978,10 +1003,13 @@ async fn reopen_is_noop_on_open_case() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let resp = app.oneshot(reopen_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(reopen_request(case_id, &cookie, &csrf)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert!(!case_dir.join(".closed").exists()); } @@ -998,18 +1026,18 @@ async fn purge_closed_removes_directory() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); assert!(case_dir.join(".closed").exists()); let resp = app - .oneshot(purge_closed_request(&cookie, Some("yes"))) + .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1030,17 +1058,17 @@ async fn purge_requires_confirm_param() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, None)) + .oneshot(purge_closed_request(&cookie, &csrf, None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -1065,17 +1093,17 @@ async fn purge_closed_keeps_open_cases() { seed_recording(&dir_closed, "10-00-00", Some("a")); seed_recording(&dir_open, "10-00-00", Some("b")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; let _ = app .clone() - .oneshot(close_request(closed_id, &cookie)) + .oneshot(close_request(closed_id, &cookie, &csrf)) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, Some("yes"))) + .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -1094,18 +1122,18 @@ async fn purge_closed_rejected_for_non_admin() { let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("ok")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; // Close the case first so there is something for purge to target. let _ = app .clone() - .oneshot(close_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie, &csrf)) .await .unwrap(); let resp = app - .oneshot(purge_closed_request(&cookie, Some("yes"))) + .oneshot(purge_closed_request(&cookie, &csrf, Some("yes"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); @@ -1128,10 +1156,10 @@ async fn bulk_close_shares_one_closed_at_timestamp() { seed_recording(&dir_a, "10-00-00", Some("a")); seed_recording(&dir_b, "10-05-00", Some("b")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let body = format!("action=close&case_id={case_a}&case_id={case_b}"); + let body = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); let resp = app .oneshot( Request::builder() @@ -1171,10 +1199,10 @@ async fn bulk_analyze_processes_each_selected_case() { seed_recording(&dir_a, "10-00-00", Some("a")); seed_recording(&dir_b, "10-05-00", Some("b")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let body = format!("action=analyze&case_id={case_a}&case_id={case_b}"); + let body = format!("action=analyze&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); let resp = app .oneshot( Request::builder() @@ -1215,12 +1243,13 @@ async fn recovery_skips_closed_cases() { // Admin-only reset // --------------------------------------------------------------------- -fn reset_request(case_id: &str, cookie: &str) -> Request { +fn reset_request(case_id: &str, cookie: &str, csrf: &str) -> Request { Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/reset")) .header(header::COOKIE, cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap() } @@ -1248,10 +1277,13 @@ async fn reset_case_clears_derived_and_unfails_audio() { std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); std::fs::write(dir.join("analysis_input.json"), "{}").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(reset_request(case_id, &cookie, &csrf)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases"); @@ -1276,10 +1308,13 @@ async fn reset_case_requires_admin() { seed_recording(&dir, "09-00-00", Some("hallo")); std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(reset_request(case_id, &cookie, &csrf)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); // Nothing touched. @@ -1309,11 +1344,11 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { seed_recording(&dir_c, "11-00-00", Some("c")); std::fs::write(dir_c.join("document.md"), "C").unwrap(); - let app = doctate_server::create_router(config); + let (app, store) = doctate_server::create_router_and_session_store(config); // Admin: bulk reset succeeds. - let cookie_admin = login(app.clone(), "dr_a").await; - let body = format!("action=reset&case_id={case_a}&case_id={case_b}"); + let (cookie_admin, csrf_admin) = login(app.clone(), &store, "dr_a").await; + let body = format!("action=reset&case_id={case_a}&case_id={case_b}&csrf_token={csrf_admin}"); let resp = app .clone() .oneshot( @@ -1334,8 +1369,8 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists()); // Non-admin: bulk reset is rejected, dr_b's case untouched. - let cookie_doctor = login(app.clone(), "dr_b").await; - let body = format!("action=reset&case_id={case_c}"); + let (cookie_doctor, csrf_doctor) = login(app.clone(), &store, "dr_b").await; + let body = format!("action=reset&case_id={case_c}&csrf_token={csrf_doctor}"); let resp = app .oneshot( Request::builder() @@ -1366,10 +1401,12 @@ async fn bulk_close_rejected_for_non_admin() { let dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&dir, "10-00-00", Some("a")); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(app.clone(), &store, "dr_a").await; - let body = format!("action=close&case_id={case_id}"); + // Include csrf_token so the admin check is the gate that fires, + // not the CSRF check — this test verifies the admin gate, not CSRF. + let body = format!("action=close&case_id={case_id}&csrf_token={csrf}"); let resp = app .oneshot( Request::builder() diff --git a/server/tests/close_watermark_test.rs b/server/tests/close_watermark_test.rs index bee5ff1..43e45b6 100644 --- a/server/tests/close_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -121,7 +121,7 @@ async fn close_bumps_watermark() { let case_id = "550e8400-e29b-41d4-a716-446655440000"; seed_case(&data_path, "dr_a", case_id); - let app = doctate_server::create_router(config); + let (app, store) = doctate_server::create_router_and_session_store(config); let login = app .clone() @@ -129,6 +129,13 @@ async fn close_bumps_watermark() { .await .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .unwrap() + .csrf_token + .clone(); // First GET: capture baseline ETag (watermark empty → seeded on 0). let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await; @@ -143,7 +150,8 @@ async fn close_bumps_watermark() { .method("POST") .uri(format!("/web/cases/{case_id}/close")) .header(header::COOKIE, &cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -175,7 +183,7 @@ async fn reopen_bumps_watermark() { let case_id = "660e8400-e29b-41d4-a716-446655440000"; seed_case(&data_path, "dr_a", case_id); - let app = doctate_server::create_router(config); + let (app, store) = doctate_server::create_router_and_session_store(config); let login = app .clone() @@ -183,6 +191,13 @@ async fn reopen_bumps_watermark() { .await .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .unwrap() + .csrf_token + .clone(); // Close first so reopen has something to restore; grab the post-close ETag. app.clone() @@ -191,7 +206,8 @@ async fn reopen_bumps_watermark() { .method("POST") .uri(format!("/web/cases/{case_id}/close")) .header(header::COOKIE, &cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -207,7 +223,8 @@ async fn reopen_bumps_watermark() { .method("POST") .uri(format!("/web/cases/{case_id}/reopen")) .header(header::COOKIE, &cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await @@ -236,7 +253,7 @@ async fn bulk_close_bumps_watermark() { seed_case(&data_path, "dr_a", case_a); seed_case(&data_path, "dr_a", case_b); - let app = doctate_server::create_router(config); + let (app, store) = doctate_server::create_router_and_session_store(config); let login = app .clone() @@ -244,12 +261,19 @@ async fn bulk_close_bumps_watermark() { .await .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .unwrap() + .csrf_token + .clone(); let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await; let etag_before = etag_before.expect("ETag must be set"); // Form-encoded bulk close: action=close&case_id=a&case_id=b. - let form = format!("action=close&case_id={case_a}&case_id={case_b}"); + let form = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}"); let bulk = app .clone() .oneshot( diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index 50cdf84..db281f8 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -3,13 +3,9 @@ //! //! 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. +//! 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; @@ -141,7 +137,6 @@ async fn session_fixation_login_rotates_cookie() { // ---------- 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); @@ -161,7 +156,6 @@ async fn bulk_without_csrf_token_forbidden() { } #[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); @@ -175,7 +169,6 @@ async fn purge_closed_without_csrf_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); @@ -193,7 +186,6 @@ async fn reset_without_csrf_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); @@ -211,7 +203,6 @@ async fn close_without_csrf_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); @@ -229,7 +220,6 @@ async fn reopen_without_csrf_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); @@ -247,7 +237,6 @@ async fn analyze_without_csrf_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); @@ -268,7 +257,6 @@ async fn delete_recording_without_csrf_forbidden() { /// 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); @@ -288,7 +276,6 @@ async fn logout_without_csrf_forbidden() { // ---------- 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); @@ -307,7 +294,6 @@ async fn bulk_with_wrong_csrf_token_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); @@ -329,7 +315,6 @@ async fn bulk_with_empty_csrf_token_forbidden() { /// 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); @@ -350,7 +335,6 @@ async fn bulk_with_whitespace_padded_token_forbidden() { /// 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); diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index 8a5e342..bab3138 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -69,13 +69,26 @@ fn login_request(slug: &str, password: &str) -> Request { .unwrap() } -async fn login(app: &axum::Router, slug: &str, password: &str) -> String { +async fn login( + 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(); - extract_session_cookie(&resp).expect("login should set session cookie") + let cookie = extract_session_cookie(&resp).expect("login should set session cookie"); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .expect("session must be in store after login") + .csrf_token + .clone(); + (cookie, csrf) } fn seed_case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { @@ -98,8 +111,8 @@ fn seed_recording(case_dir: &Path, ts_stem: &str, transcript: &str) -> String { filename } -fn delete_request(case_id: &str, filename: &str, cookie: &str) -> Request { - let body = format!("filename={filename}"); +fn delete_request(case_id: &str, filename: &str, cookie: &str, csrf: &str) -> Request { + let body = format!("filename={filename}&csrf_token={csrf}"); Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/recordings/delete")) @@ -124,12 +137,12 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { std::fs::write(case_dir.join("document.md"), b"# stale").unwrap(); std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &victim, &cookie)) + .oneshot(delete_request(case_id, &victim, &cookie, &csrf)) .await .unwrap(); assert_eq!( @@ -184,12 +197,17 @@ async fn delete_recording_rejects_path_traversal() { let case_dir = seed_case_dir(&data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-19T10-00-00Z", "safe"); - let app = doctate_server::create_router(config); - let cookie = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, "../../../etc/passwd.m4a", &cookie)) + .oneshot(delete_request( + case_id, + "../../../etc/passwd.m4a", + &cookie, + &csrf, + )) .await .unwrap(); assert_eq!( @@ -209,12 +227,12 @@ async fn delete_recording_rejects_wrong_extension() { let case_id = "33333333-3333-3333-3333-333333333333"; seed_case_dir(&data_path, "dr_a", case_id); - let app = doctate_server::create_router(config); - let cookie = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, "document.md", &cookie)) + .oneshot(delete_request(case_id, "document.md", &cookie, &csrf)) .await .unwrap(); assert_eq!( @@ -233,12 +251,12 @@ async fn delete_recording_cross_user_returns_404() { let case_dir = seed_case_dir(&data_path, "dr_b", case_id); let filename = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "bob's recording"); - let app = doctate_server::create_router(config); - let cookie_a = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie_a, csrf_a) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &filename, &cookie_a)) + .oneshot(delete_request(case_id, &filename, &cookie_a, &csrf_a)) .await .unwrap(); assert_eq!( @@ -262,12 +280,12 @@ async fn delete_last_recording_clears_case() { let only = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "lonely"); std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() - .oneshot(delete_request(case_id, &only, &cookie)) + .oneshot(delete_request(case_id, &only, &cookie, &csrf)) .await .unwrap(); assert_eq!( @@ -291,8 +309,8 @@ async fn delete_recording_accepts_failed_suffix() { let case_dir = seed_case_dir(&data_path, "dr_a", case_id); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a.failed"), b"x").unwrap(); - let app = doctate_server::create_router(config); - let cookie = login(&app, "dr_a", "s").await; + let (app, store) = doctate_server::create_router_and_session_store(config); + let (cookie, csrf) = login(&app, &store, "dr_a", "s").await; let resp = app .clone() @@ -300,6 +318,7 @@ async fn delete_recording_accepts_failed_suffix() { case_id, "2026-04-19T10-00-00Z.m4a.failed", &cookie, + &csrf, )) .await .unwrap(); diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index a898bd1..40f4b60 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -222,7 +222,7 @@ async fn case_detail_of_foreign_case_returns_404() { #[tokio::test] async fn logout_clears_session() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); - let app = doctate_server::create_router(config); + let (app, store) = doctate_server::create_router_and_session_store(config); let login = app .clone() @@ -230,6 +230,8 @@ async fn logout_clears_session() { .await .unwrap(); let cookie = extract_session_cookie(&login).unwrap(); + let token = cookie.trim_start_matches("session="); + let csrf = store.read().await.get(token).unwrap().csrf_token.clone(); let logout = app .clone() @@ -238,7 +240,8 @@ async fn logout_clears_session() { .method("POST") .uri("/web/logout") .header(header::COOKIE, &cookie) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("csrf_token={csrf}"))) .unwrap(), ) .await