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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user