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