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