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
+10 -2
View File
@@ -2,7 +2,6 @@ use std::sync::Arc;
use axum::extract::State;
use axum::response::Redirect;
use axum_extra::extract::Form;
use doctate_common::timestamp::now_rfc3339;
use serde::Deserialize;
use tracing::{info, warn};
@@ -10,6 +9,7 @@ use tracing::{info, warn};
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::csrf::{CsrfForm, HasCsrfToken};
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::paths::{CloseMarker, write_close_marker};
@@ -25,6 +25,14 @@ pub struct BulkForm {
/// (handled as a no-op).
#[serde(default, rename = "case_id")]
case_ids: Vec<String>,
#[serde(default)]
csrf_token: String,
}
impl HasCsrfToken for BulkForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to
@@ -35,7 +43,7 @@ pub async fn handle_bulk_action(
State(config): State<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
State(events_tx): State<EventSender>,
Form(form): Form<BulkForm>,
CsrfForm(form): CsrfForm<BulkForm>,
) -> Result<Redirect, AppError> {
// Bulk actions are admin-only across the board: the UI hides the
// selection checkboxes and action bar from non-admins, and this