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