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
+114
View File
@@ -0,0 +1,114 @@
//! CSRF-token validation for state-changing POSTs under `/web/`.
//!
//! The `WebSession` carries a `csrf_token` minted once at login or
//! magic-link consume. Browser forms include a hidden `csrf_token`
//! field populated from the session. Handlers that mutate server
//! state use [`CsrfForm<T>`] instead of `Form<T>` to enforce that the
//! submitted token matches the session's token. The session cookie
//! alone is not sufficient: `SameSite=Strict` already blocks most
//! cross-site POSTs, but the token is the defence that also holds
//! against attackers with cross-site injection on a same-site
//! subdomain or misconfigured browser.
use std::sync::Arc;
use std::time::Instant;
use axum::extract::{FromRef, FromRequest, Request};
use axum_extra::extract::{CookieJar, Form};
use serde::de::DeserializeOwned;
use subtle::ConstantTimeEq;
use tracing::warn;
use crate::config::Config;
use crate::error::AppError;
use crate::web_session::{SESSION_COOKIE, SessionStore};
/// Every form struct protected by CSRF implements this trait so the
/// extractor can pull the token out of the decoded body. A plain
/// `#[serde(default)] pub csrf_token: String` plus a three-line impl
/// of this trait is all the boilerplate each form needs.
pub trait HasCsrfToken {
fn csrf_token(&self) -> &str;
}
/// Wrapping extractor — a drop-in replacement for `Form<T>` on
/// state-changing `/web/` POST handlers. Validates the form body's
/// `csrf_token` against the session's csrf_token via a constant-time
/// comparison.
///
/// Rejection mapping:
/// - Missing or expired session → 302 redirect to `/web/login` (so a
/// second-tab logout shows "bitte neu anmelden", not "csrf failed").
/// - Form body malformed → 400 `BadRequest`.
/// - Token present but mismatched → 403 `Forbidden`.
pub struct CsrfForm<T>(pub T);
impl<S, T> FromRequest<S> for CsrfForm<T>
where
S: Send + Sync,
T: DeserializeOwned + HasCsrfToken + Send + 'static,
SessionStore: FromRef<S>,
Arc<Config>: FromRef<S>,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let store = SessionStore::from_ref(state);
// Look up the expected token first (headers only — the body
// extractor below will consume `req`).
let cookie_token = CookieJar::from_headers(req.headers())
.get(SESSION_COOKIE)
.map(|c| c.value().to_owned())
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
let expected = {
let r = store.read().await;
let session = r
.get(&cookie_token)
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
if session.expires_at <= Instant::now() {
return Err(AppError::Redirect("/web/login".into()));
}
session.csrf_token.clone()
};
// Consume the body. `axum_extra::extract::Form` supports
// repeated fields (BulkForm's `Vec<String>`); plain
// `axum::extract::Form` does not.
let Form(form): Form<T> = Form::from_request(req, state)
.await
.map_err(|e| AppError::BadRequest(format!("form parse: {e}")))?;
// Constant-time compare. `ConstantTimeEq` on byte slices
// returns `Choice(0)` when the lengths differ, so empty or
// malformed tokens fall through here without a special case.
let provided = form.csrf_token();
if !bool::from(provided.as_bytes().ct_eq(expected.as_bytes())) {
warn!(
provided_len = provided.len(),
expected_len = expected.len(),
"csrf token mismatch"
);
return Err(AppError::Forbidden("CSRF-Token ungültig oder fehlt".into()));
}
Ok(CsrfForm(form))
}
}
/// Form body with only a CSRF token — for handlers that are
/// state-changing but carry no other user-submitted data
/// (close/reopen/reset/analyze/logout). Using one shared type beats
/// five wordless copy-paste structs.
#[derive(serde::Deserialize)]
pub struct CsrfOnlyForm {
#[serde(default)]
pub csrf_token: String,
}
impl HasCsrfToken for CsrfOnlyForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
+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 {
+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
+24 -3
View File
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use axum::extract::{Form, State};
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::Redirect;
use serde::Deserialize;
@@ -21,6 +21,7 @@ use crate::analyze::{
use crate::auth::AuthenticatedWebUser;
use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::csrf::{CsrfForm, CsrfOnlyForm, HasCsrfToken};
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::paths::{
@@ -43,6 +44,7 @@ pub async fn handle_analyze_case(
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
_csrf: CsrfForm<CsrfOnlyForm>,
) -> Result<Redirect, AppError> {
if !config.llm_configured() {
return Err(AppError::ServiceUnavailable(
@@ -323,6 +325,7 @@ pub async fn handle_close_case(
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
_csrf: CsrfForm<CsrfOnlyForm>,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?;
@@ -365,6 +368,7 @@ pub async fn handle_reopen_case(
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
_csrf: CsrfForm<CsrfOnlyForm>,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = user_root.join(case_id.to_string());
@@ -414,6 +418,7 @@ pub async fn handle_reset_case(
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
_csrf: CsrfForm<CsrfOnlyForm>,
) -> Result<Redirect, AppError> {
if !user.is_admin() {
return Err(AppError::Forbidden("Nur für Admins".into()));
@@ -442,6 +447,14 @@ pub struct PurgeClosedForm {
// rather than a 422 from axum's form extractor.
#[serde(default)]
pub confirm: String,
#[serde(default)]
pub csrf_token: String,
}
impl HasCsrfToken for PurgeClosedForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// POST /web/cases/purge-closed
@@ -458,7 +471,7 @@ pub async fn handle_purge_closed(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(events_tx): State<EventSender>,
Form(form): Form<PurgeClosedForm>,
CsrfForm(form): CsrfForm<PurgeClosedForm>,
) -> Result<Redirect, AppError> {
// Destructive, irreversible — admin-only. UI hides the button for
// non-admins; this is the server-side defense-in-depth gate.
@@ -517,6 +530,14 @@ pub async fn handle_purge_closed(
#[derive(Deserialize)]
pub struct DeleteRecordingForm {
pub filename: String,
#[serde(default)]
pub csrf_token: String,
}
impl HasCsrfToken for DeleteRecordingForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// POST /web/cases/{case_id}/recordings/delete
@@ -538,7 +559,7 @@ pub async fn handle_delete_recording(
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
Form(form): Form<DeleteRecordingForm>,
CsrfForm(form): CsrfForm<DeleteRecordingForm>,
) -> Result<Redirect, AppError> {
validate_filename(&form.filename)?;
+4
View File
@@ -10,6 +10,7 @@ use serde::Deserialize;
use tracing::{info, warn};
use crate::config::Config;
use crate::csrf::{CsrfForm, CsrfOnlyForm};
use crate::error::AppError;
use crate::web_session::{
SESSION_COOKIE, SessionStore, WebSession, build_session_cookie, generate_token,
@@ -57,6 +58,7 @@ pub async fn handle_login_submit(
let user = user.unwrap();
let token = generate_token();
let csrf_token = generate_token();
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
{
@@ -67,6 +69,7 @@ pub async fn handle_login_submit(
slug: user.slug.clone(),
role: user.role.clone(),
expires_at: Instant::now() + ttl,
csrf_token,
},
);
}
@@ -82,6 +85,7 @@ pub async fn handle_logout(
State(config): State<Arc<Config>>,
State(store): State<SessionStore>,
jar: CookieJar,
_csrf: CsrfForm<CsrfOnlyForm>,
) -> Result<Response, AppError> {
if let Some(c) = jar.get(SESSION_COOKIE) {
let token = c.value().to_owned();
+2
View File
@@ -119,6 +119,7 @@ pub async fn handle_consume(
}
let session_token = generate_token();
let csrf_token = generate_token();
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
{
let mut w = session_store.write().await;
@@ -128,6 +129,7 @@ pub async fn handle_consume(
slug: pending.slug.clone(),
role: pending.role.clone(),
expires_at: Instant::now() + ttl,
csrf_token,
},
);
}
+5
View File
@@ -11,6 +11,11 @@ pub struct WebSession {
pub slug: String,
pub role: String,
pub expires_at: Instant,
/// CSRF token — bound to the session for its full lifetime. Minted
/// alongside the session cookie via [`generate_token`] and validated
/// by the `CsrfForm` extractor on every state-changing POST under
/// `/web/`. Not rotated per request (would break multi-tab use).
pub csrf_token: String,
}
pub type SessionStore = Arc<RwLock<HashMap<String, WebSession>>>;