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
+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();