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
+15 -3
View File
@@ -2,6 +2,7 @@ pub mod analyze;
pub mod auth;
pub mod case_id;
pub mod config;
pub mod csrf;
pub mod error;
pub mod events;
pub mod gazetteer;
@@ -178,13 +179,23 @@ impl FromRef<AppState> for Arc<Gazetteer> {
/// because the receivers are not retained. Use [`create_router_with_state`]
/// from `main.rs` where real workers own the receivers.
pub fn create_router(config: Arc<Config>) -> Router {
create_router_and_session_store(config).0
}
/// Same as [`create_router`], but also returns a handle to the newly
/// created session store so tests can peek at `WebSession::csrf_token`
/// values without rendering and parsing HTML. The store handle is
/// shared with the router (`Arc`), so any session created via `/web/login`
/// or `/web/magic` becomes observable through it.
pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionStore) {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
create_router_with_state(AppState {
let session_store = web_session::new_store();
let router = create_router_with_state(AppState {
config,
transcribe_tx,
analyze_tx,
session_store: web_session::new_store(),
session_store: session_store.clone(),
magic_link_store: magic_link::new_store(),
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
@@ -192,7 +203,8 @@ pub fn create_router(config: Arc<Config>) -> Router {
events_tx: events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(Gazetteer::empty()),
})
});
(router, session_store)
}
pub fn create_router_with_state(state: AppState) -> Router {