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:
+15
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user