feat: server-side CSRF token validation

Introduces CsrfForm<T>: a FromRequest extractor that looks up the
session's csrf_token and constant-time-compares it to a csrf_token
field in the form body. Drop-in replacement for Form<T> on every
state-changing /web/ POST handler. Missing or expired session →
redirect to /web/login; malformed body → 400; token mismatch → 403.

WebSession carries csrf_token, minted alongside the session token at
login and magic-link consume. Not rotated per request — rotation
would break multi-tab use and buys little over SameSite=Strict
cookies.

Handlers: bulk, purge-closed, reset, close, reopen, analyze,
delete-recording, logout all now require CsrfForm<_>. Login stays
unprotected (SameSite=Strict alone is sufficient — forced-login CSRF
has no impact on this codebase). /api/... is header-auth, exempt.

Constant-time compare via subtle::ConstantTimeEq avoids timing
oracles on the token.

Template work is NOT in this commit — browser forms still post
without csrf_token, so the live web UI will 403 until Schritt 6
(templates render the hidden field).

Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore]
removed. Integration tests that POSTed to protected endpoints
switched to a new create_router_and_session_store entrypoint which
returns a handle to the store so tests can read csrf_token for the
active session.
This commit is contained in:
2026-04-22 09:59:17 +02:00
parent f3d0380dbd
commit bf3e9ba6cc
14 changed files with 397 additions and 162 deletions
+142 -105
View File
@@ -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<Body> {
fn analyze_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
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<Body> {
fn close_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
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<Body> {
fn reopen_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
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<Body> {
let body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default();
fn purge_closed_request(cookie: &str, csrf: &str, confirm: Option<&str>) -> Request<Body> {
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<Body> {
fn reset_request(case_id: &str, cookie: &str, csrf: &str) -> Request<Body> {
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()
+31 -7
View File
@@ -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(
+3 -19
View File
@@ -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<T>` 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<T>` 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);
+40 -21
View File
@@ -69,13 +69,26 @@ fn login_request(slug: &str, password: &str) -> Request<Body> {
.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<Body> {
let body = format!("filename={filename}");
fn delete_request(case_id: &str, filename: &str, cookie: &str, csrf: &str) -> Request<Body> {
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();
+5 -2
View File
@@ -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