//! CSRF (Cross-Site Request Forgery) protection //! //! Uses the synchronizer token pattern: //! 1. Generate random token on session start //! 2. Include token in forms/meta tag //! 3. Validate token on state-changing requests use axum::{ Router, extract::{FromRequestParts, Request}, handler::Handler, http::{StatusCode, header::HeaderMap, request::Parts}, middleware::{Next, from_fn}, response::{IntoResponse, Response}, routing::{MethodRouter, delete, patch, post, put}, }; use rand::Rng; use std::collections::BTreeMap; use std::sync::{LazyLock, Mutex}; use tower_sessions::Session; use crate::error::{AppError, ResultExt}; // --- Route manifest ------------------------------------------------------ // // Every mutating route is registered through `CsrfRouter` (the structural // seal), so route registration is the one place that sees every state-changing // path and its declared posture. We harvest that into a process-global manifest // as a side effect of registration. It exists for the router-coverage test // (`tests/workflows/csrf_coverage.rs`), which asserts the whole-router CSRF // invariant in one assertion instead of relying on per-route tests or an audit // to notice a route that drifted. Populated after `build_app` has run once. /// Posture kind recorded in the manifest, reason-free and `'static`-free so it /// can live in a process-global. Derived from [`CsrfPosture`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ManifestPosture { Auto, Manual, Skip, } /// One mutating route's declared CSRF posture, harvested at registration time. #[derive(Clone, Debug)] pub struct CsrfRouteEntry { pub path: String, pub posture: ManifestPosture, /// Documented justification for Manual/Skip; `None` for Auto. pub reason: Option<&'static str>, } /// Keyed by path: a path has exactly one posture (multi-method routes share it, /// and Axum forbids registering a path twice). Re-registration across /// `build_app` rebuilds (every test harness builds a fresh app) is idempotent, /// the same key overwrites with identical data. static CSRF_MANIFEST: LazyLock>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); fn record_route(path: &str, posture: CsrfPosture) { let (posture, reason) = match posture { CsrfPosture::Auto => (ManifestPosture::Auto, None), CsrfPosture::Manual(r) => (ManifestPosture::Manual, Some(r)), CsrfPosture::Skip(r) => (ManifestPosture::Skip, Some(r)), }; CSRF_MANIFEST.lock().unwrap().insert( path.to_string(), CsrfRouteEntry { path: path.to_string(), posture, reason, }, ); } /// Snapshot of every mutating route registered through [`CsrfRouter`], with its /// declared CSRF posture. Populated as a side effect of route registration, so /// it is only complete after `build_app` has run at least once in the process. pub fn route_manifest() -> Vec { CSRF_MANIFEST.lock().unwrap().values().cloned().collect() } /// Session key for storing CSRF token pub const CSRF_SESSION_KEY: &str = "csrf_token"; /// CSRF token length in bytes (32 bytes = 256 bits) const CSRF_TOKEN_LENGTH: usize = 32; /// Generate a new CSRF token pub fn generate_token() -> String { let mut token = [0u8; CSRF_TOKEN_LENGTH]; rand::rng().fill_bytes(&mut token); hex::encode(token) } /// Get or create a CSRF token for the session. /// /// `tower-sessions`' `insert` is last-write-wins, so two concurrent first /// requests (e.g. the user opens two tabs while not yet having a token) /// can each generate a fresh token and clobber each other at store-save time, /// the losing tab's rendered token is then stale and its first mutation gets a /// 403, after which a reload picks up the winning token. This is a rare, /// self-correcting edge for the brief window before a session has any token; it /// cannot be reconciled in-process because each request holds an independent /// session load (see the note in the body). pub async fn get_or_create_token(session: &Session) -> Result { if let Some(token) = session .get::(CSRF_SESSION_KEY) .await .context("session error")? { return Ok(token); } let candidate = generate_token(); session .insert(CSRF_SESSION_KEY, &candidate) .await .context("session insert")?; // Return the token we just inserted. (A prior version re-read the key here, // claiming to reconcile a concurrent insert, but two concurrent // first-requests sharing a cookie each hold an independent session load, so // the re-read only ever observes THIS request's own insert and returned // `candidate` unconditionally. The store reconciles the tabs last-writer-wins // at save time; a losing tab may hit one 403 and retry. The re-read was a // no-op, so it's gone.) Ok(candidate) } /// Validate a CSRF token against the session token pub async fn validate_token(session: &Session, provided_token: &str) -> Result { let session_token: Option = session .get(CSRF_SESSION_KEY) .await .context("session error")?; match session_token { Some(token) => Ok(crate::helpers::constant_time_compare( &token, provided_token, )), None => Ok(false), } } /// Token for a Manual-posture handler, which has an already-parsed form rather /// than a raw body: the `X-CSRF-Token` header first, then the deserialized /// `_csrf` field. /// /// Separate from [`extract_token_from_request`] because that one takes the raw /// urlencoded *body* and searches it for a `_csrf=` key. Handing it the field's /// *value* instead looks right and silently yields `None`: the parser reads the /// token itself as a key with an empty value, finds no `_csrf`, and the caller's /// `unwrap_or_default()` turns that into an empty token that can never validate. /// /// Both Manual call sites did exactly that, so `POST /login` and the vanilla tip /// form answered 403 to every request that did not come from HTMX. HTMX sends /// the header and takes the first branch, which is why the whole site worked and /// only the no-JS form path was dead. Found 2026-08-07 driving the landing /// carousel capture, which posts a plain form on purpose. pub fn token_from_header_or_field(headers: &HeaderMap, field: Option<&str>) -> Option { if let Some(token) = headers .get("X-CSRF-Token") .and_then(|v| v.to_str().ok()) .map(std::string::ToString::to_string) { return Some(token); } field.map(std::string::ToString::to_string) } /// Extract CSRF token from request (header or the raw form-encoded **body**). /// /// `body` is the whole urlencoded body, not a single field. A handler holding a /// deserialized form wants [`token_from_header_or_field`] instead. pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Option { // Try the X-CSRF-Token header (used by HTMX) if let Some(token) = headers .get("X-CSRF-Token") .and_then(|v| v.to_str().ok()) .map(std::string::ToString::to_string) { return Some(token); } // Fall back to the `_csrf` field in form-encoded body (vanilla HTML // forms). We use a proper urlencoded parser instead of `split('&')` // so a textarea containing `&_csrf=attacker-token` can't sneak past // a later field with the wrong value, the parser respects field // ordering and won't conflate textarea content with form fields // because the form encoder percent-encodes `&` inside text values. if let Some(body_str) = body { for (key, value) in url::form_urlencoded::parse(body_str.as_bytes()) { if key == "_csrf" { return Some(value.into_owned()); } } } None } /// Extractor for CSRF token from session pub struct CsrfToken(pub String); impl FromRequestParts for CsrfToken where S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let token = get_or_create_token(session).await?; Ok(CsrfToken(token)) } } /// Per-route CSRF posture, declared at the route registration site via the /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures /// so the choice (and its reason) lives next to the route, not in a sibling /// allowlist file. The structural guarantee comes from `CsrfRouter` only /// accepting `PostureMethodRouter` values; the posture is also carried on the /// `PostureMethodRouter` so `CsrfRouter::route` can harvest it into the /// [`route_manifest`] consumed by the router-coverage test. #[derive(Clone, Copy, Debug)] pub enum CsrfPosture { /// Standard validation layer runs (header or form `_csrf`). Auto, /// Handler validates the token itself and proves it with the /// `CsrfManuallyValidated` witness. Reason documents why the /// standard layer can't apply (e.g. "multipart upload"). Manual(&'static str), /// No CSRF check applies. Reason documents why (webhook signature, /// signed link, pre-auth, etc.). Skip(&'static str), } /// Witness type proving a handler ran the standard CSRF validation path. /// The only public way to obtain one is `validate_token_consuming`, which /// performs the check. The private field with a private-module constructor /// makes the value un-fabricable from outside this module, `Default`, /// struct-literal, and `Clone` are all impossible for callers. pub use sealed::CsrfManuallyValidated; mod sealed { pub struct CsrfManuallyValidated { _private: (), } pub(super) fn make_validated() -> CsrfManuallyValidated { CsrfManuallyValidated { _private: () } } } /// Validate a token and return a sealed witness on success. Used by /// handlers registered with `post_csrf_manual` (and method variants) /// that need to validate inside the handler body, typically because the /// global middleware can't read the token for this content type (e.g. /// multipart) or because validation is conditional on request state. pub async fn validate_token_consuming( session: &Session, provided_token: &str, ) -> Result { if validate_token(session, provided_token).await? { Ok(sealed::make_validated()) } else { Err(AppError::Forbidden) } } // Manual-posture runtime assertion (dev/test only): attempted via a tokio // task-local flag set in `validate_token_consuming` and checked in a per- // route layer. Backed out 2026-05-27, false-positive density was too high: // rendered error pages return 200, rate-limit and form-extraction // short-circuit before the handler, and the audit explicitly marked this // follow-up as "not blocking, only matters if Manual grows beyond one // route". Compile-time discipline (the `CsrfManuallyValidated` witness type // bound as `_validated`) stays the convention. /// Wrap a method-router with the Auto-posture validation layer. /// Runs `validate_auto` on every request that reaches the route. fn attach_auto_layer(method_router: MethodRouter) -> MethodRouter where S: Clone + Send + Sync + 'static, { method_router.layer(from_fn(|req: Request, next: Next| async move { let path = req.uri().path().to_string(); validate_auto(req, next, &path).await })) } /// A `MethodRouter` that has been through one of the CSRF helpers. Field /// is private and constructible only inside this module, so /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`; /// route files have to use the helpers, by construction. pub use posture_router::PostureMethodRouter; mod posture_router { use super::{CsrfPosture, MethodRouter}; pub struct PostureMethodRouter { inner: MethodRouter, posture: CsrfPosture, } impl PostureMethodRouter where S: Clone + Send + Sync + 'static, { pub(super) fn new(inner: MethodRouter, posture: CsrfPosture) -> Self { Self { inner, posture } } pub(super) fn into_inner(self) -> MethodRouter { self.inner } pub(super) fn posture(&self) -> CsrfPosture { self.posture } /// Attach an additional tower layer (e.g. a rate limiter) to the /// underlying method router. Returns `Self` so callers don't lose /// the posture stamp. #[must_use] pub fn layer(self, layer: L) -> Self where L: tower::Layer + Clone + Send + Sync + 'static, L::Service: tower::Service + Clone + Send + Sync + 'static, >::Response: axum::response::IntoResponse + 'static, >::Error: Into + 'static, >::Future: Send + 'static, { Self { inner: self.inner.layer(layer), posture: self.posture, } } } } macro_rules! csrf_auto_helper { ($name:ident, $axum_fn:ident) => { pub fn $name(handler: H) -> PostureMethodRouter where H: Handler, T: 'static, S: Clone + Send + Sync + 'static, { posture_router::PostureMethodRouter::new( attach_auto_layer($axum_fn(handler)), CsrfPosture::Auto, ) } }; } macro_rules! csrf_passthrough_helper { ($name:ident, $axum_fn:ident, $variant:ident) => { pub fn $name(reason: &'static str, handler: H) -> PostureMethodRouter where H: Handler, T: 'static, S: Clone + Send + Sync + 'static, { posture_router::PostureMethodRouter::new( $axum_fn(handler), CsrfPosture::$variant(reason), ) } }; } // Auto posture: standard CSRF validation (header or form `_csrf`). csrf_auto_helper!(post_csrf, post); csrf_auto_helper!(put_csrf, put); csrf_auto_helper!(patch_csrf, patch); csrf_auto_helper!(delete_csrf, delete); // Manual posture: handler validates via `validate_token_consuming`. csrf_passthrough_helper!(post_csrf_manual, post, Manual); csrf_passthrough_helper!(put_csrf_manual, put, Manual); csrf_passthrough_helper!(patch_csrf_manual, patch, Manual); csrf_passthrough_helper!(delete_csrf_manual, delete, Manual); // Skip posture: no CSRF check. Reason documents why. csrf_passthrough_helper!(post_csrf_skip, post, Skip); csrf_passthrough_helper!(put_csrf_skip, put, Skip); csrf_passthrough_helper!(patch_csrf_skip, patch, Skip); csrf_passthrough_helper!(delete_csrf_skip, delete, Skip); // --- Wrappers for multi-method routes ------------------------------------ // // A handful of routes register multiple HTTP methods on one path // (e.g. `get(list).post(create)`). The handler-taking helpers above can't // compose with these because the chain is already a `MethodRouter`. These // wrappers take a pre-built `MethodRouter` and stamp it as a // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected, the // Auto validation layer only intercepts state-changing methods at the // per-route level because that's what the helper attached to. /// Wrap a multi-method chain with the Auto-posture validation layer. pub fn with_csrf(method_router: MethodRouter) -> PostureMethodRouter where S: Clone + Send + Sync + 'static, { posture_router::PostureMethodRouter::new(attach_auto_layer(method_router), CsrfPosture::Auto) } /// Stamp a multi-method chain as Manual, handler is responsible for /// calling `validate_token_consuming`. pub fn with_csrf_manual( reason: &'static str, method_router: MethodRouter, ) -> PostureMethodRouter where S: Clone + Send + Sync + 'static, { posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Manual(reason)) } /// Stamp a multi-method chain as Skip, no CSRF check applies. pub fn with_csrf_skip( reason: &'static str, method_router: MethodRouter, ) -> PostureMethodRouter where S: Clone + Send + Sync + 'static, { posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Skip(reason)) } // --- Origin gate: posture-independent pre-auth seal ---------------------- // // Applied once to the whole `CsrfRouter` tree in `finalize`, so it covers // every registered route, Auto, Manual, and Skip alike, and runs before any // per-route posture. It closes the pre-auth forgery vector (CHRONIC A'): the // per-route `validate_auto` deliberately does NOT require a token from a // logged-out caller (the public form path relies on the anonymous-session // token), so without this gate a cross-site forged POST to a public form such // as `/forgot-password` would reach the handler. // // Policy: reject only when the request is *positively* identified as // cross-site. A request carrying no origin signal at all is allowed through, // every modern browser sends `Sec-Fetch-Site`, so the no-signal case is // non-browser traffic (Stripe webhooks, OAuth callbacks, mnw-cli, curl) that // cannot be driven cross-site from a victim's browser. This keeps server-to- // server and CLI clients working while sealing the browser forgery path. fn is_mutating(method: &axum::http::Method) -> bool { matches!( *method, axum::http::Method::POST | axum::http::Method::PUT | axum::http::Method::PATCH | axum::http::Method::DELETE ) } /// Host (no scheme, no port, lowercased) from an `Origin`/`Referer` value. /// `None` for opaque origins (`"null"`), scheme-less values, or anything /// unparseable, callers treat `None` as "no usable signal" (allow). fn url_host(value: &str) -> Option { let rest = value .strip_prefix("https://") .or_else(|| value.strip_prefix("http://"))?; let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); // Drop any userinfo (defensive; Origin never carries it). let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); Some(strip_port(authority)).filter(|h| !h.is_empty()) } /// The request's own host from the `Host` header, port stripped, lowercased. fn request_host(headers: &axum::http::HeaderMap) -> Option { let raw = headers.get(axum::http::header::HOST)?.to_str().ok()?; Some(strip_port(raw)).filter(|h| !h.is_empty()) } /// Strip a trailing `:port`, handle bracketed IPv6 literals, and lowercase. fn strip_port(authority: &str) -> String { let host = if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) { // `[::1]:8080` -> `[::1]` &authority[..end + 2] } else { authority.split(':').next().unwrap_or(authority) }; host.to_ascii_lowercase() } /// Returns true only when the request is *positively* identified as cross-site. /// Absent or ambiguous signals return false (allow). See [`origin_gate`]. fn is_cross_site(headers: &axum::http::HeaderMap) -> bool { // 1. Sec-Fetch-Site (sent by every modern browser) is authoritative. if let Some(sfs) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) { // same-origin | same-site | none (user-initiated) => not cross-site. return sfs.eq_ignore_ascii_case("cross-site"); } // 2. Header-less clients: a present Origin/Referer host must match Host. // No Host to compare against, or no usable origin host => allow. let Some(host) = request_host(headers) else { return false; }; if let Some(origin) = headers .get(axum::http::header::ORIGIN) .and_then(|v| v.to_str().ok()) { return url_host(origin).is_some_and(|h| h != host); } if let Some(referer) = headers .get(axum::http::header::REFERER) .and_then(|v| v.to_str().ok()) { return url_host(referer).is_some_and(|h| h != host); } false } /// Posture-independent origin gate; see the module section comment above. async fn origin_gate(request: Request, next: Next) -> Response { if is_mutating(request.method()) && is_cross_site(request.headers()) { tracing::warn!( path = %request.uri().path(), "CSRF origin gate: cross-site mutation rejected" ); return crate::error::AppError::Forbidden.into_response(); } next.run(request).await } // --- CsrfRouter: structural enforcement ---------------------------------- // // `CsrfRouter` is the only way to register a mutation route in this // codebase. Its `route` method takes a `PostureMethodRouter`, whose // constructor is private to this module, so the only producers are the // helpers above. A bare `Router::route(path, post(handler))` cannot // reach a mounted `CsrfRouter` without going through `finalize()` first, // which is only called once in `build_app`. pub struct CsrfRouter(Router); impl Default for CsrfRouter where S: Clone + Send + Sync + 'static, { fn default() -> Self { Self::new() } } impl CsrfRouter where S: Clone + Send + Sync + 'static, { pub fn new() -> Self { Self(Router::new()) } #[must_use] pub fn route(self, path: &str, posture: PostureMethodRouter) -> Self { record_route(path, posture.posture()); Self(self.0.route(path, posture.into_inner())) } /// Register a read-only route (GET / HEAD / OPTIONS). The structural /// guarantee only constrains state-changing methods, so read-only /// `MethodRouter`s pass through unchanged. Calling this with a /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but /// readers can see the intent at the call site, and any mutation /// route registered through `route_get` is a bug visible in review. #[must_use] pub fn route_get(self, path: &str, method_router: MethodRouter) -> Self { Self(self.0.route(path, method_router)) } #[must_use] pub fn merge(self, other: Self) -> Self { Self(self.0.merge(other.0)) } #[must_use] pub fn nest(self, path: &str, other: Self) -> Self { Self(self.0.nest(path, other.0)) } #[must_use] pub fn layer(self, layer: L) -> Self where L: tower::Layer + Clone + Send + Sync + 'static, L::Service: tower::Service + Clone + Send + Sync + 'static, >::Response: IntoResponse + 'static, >::Error: Into + 'static, >::Future: Send + 'static, { Self(self.0.layer(layer)) } #[must_use] pub fn route_layer(self, layer: L) -> Self where L: tower::Layer + Clone + Send + Sync + 'static, L::Service: tower::Service + Clone + Send + Sync + 'static, >::Response: IntoResponse + 'static, >::Error: Into + 'static, >::Future: Send + 'static, { Self(self.0.route_layer(layer)) } /// Drop the structural envelope and return the underlying `Router`. /// Called once in `build_app` after all mutation routes have been /// registered; downstream code may then attach global layers, mount /// static-file services, and add GET-only routes. /// /// The posture-independent [`origin_gate`] is layered on here so it wraps /// every route registered through THIS `CsrfRouter` (the one non-skippable /// CSRF check covering all postures at once); safe methods pass through, so /// the later-added GET/static routes are unaffected. /// /// Carve-out (ultra-fuzz Run 4): routers merged into the app OUTSIDE the /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this /// `origin_gate`. That is sound because those surfaces are GET-only or /// authenticated by a PAT/bearer rather than a session cookie (so they are /// legitimately CSRF-exempt). The one mutating route among them, git /// `receive-pack` (push), ENFORCES this: `authorize_push` requires a /// push-scoped PAT (`token_push == Some(true)`) and rejects session-cookie /// auth, so a cross-origin cookie POST cannot drive a write (UX-S1, Run 7). /// Any future cookie-authed POST added to one of these surfaces must route /// through a `CsrfRouter`, not be merged raw. pub fn finalize(self) -> Router { self.0.layer(from_fn(origin_gate)) } } /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes /// and by the path-allowlist fallback during the L2 migration. async fn validate_auto(request: Request, next: Next, path: &str) -> Response { // Safe methods (RFC 9110 §9.2.1) are read-only by definition, never // CSRF-check them. This matters for multi-method routes wrapped by // `with_csrf(get(load).post(save))`: a bare GET should not require a // token (and the harness doesn't send one for GETs). if !matches!( *request.method(), axum::http::Method::POST | axum::http::Method::PUT | axum::http::Method::PATCH | axum::http::Method::DELETE ) { return next.run(request).await; } // Get session from extensions let session = match request.extensions().get::() { Some(s) => s.clone(), None => { tracing::warn!("CSRF check failed: no session"); return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response(); } }; // Try header first (HTMX requests) let header_token = request .headers() .get("X-CSRF-Token") .and_then(|v| v.to_str().ok()) .map(std::string::ToString::to_string); if let Some(ref token) = header_token { return match validate_token(&session, token).await { Ok(true) => next.run(request).await, Ok(false) => { tracing::warn!(path = %path, "CSRF token mismatch"); crate::error::AppError::Forbidden.into_response() } Err(e) => { tracing::error!(error = ?e, "CSRF validation error"); crate::error::AppError::Internal(anyhow::anyhow!("CSRF validation error")) .into_response() } }; } // No header token, fall through to the form-body `_csrf` check. // // CHRONIC A' (2026-06-15): this previously skipped validation entirely for // logged-out callers (`if !has_user { return next.run ... }`), which let a // cross-site forged POST to a public form (e.g. `/forgot-password`) reach // the handler. The skip is gone: every mutating request now requires a // valid token regardless of auth state. Legitimate logged-out forms carry // one, `get_or_create_token` stamps the anonymous session on the GET // render and the template embeds `_csrf`. The posture-independent // `origin_gate` (see `finalize`) is the complementary seal for browser // forgery; this token check additionally covers header-less forged clients // that the origin gate intentionally lets through. // We only parse `application/x-www-form-urlencoded`. Other content // types are rejected here: // - `multipart/form-data` is the closest near-miss: it has its own // `_csrf` part but parsing it would mean pulling in a multipart // decoder and buffering the entire upload body, defeating the // upload-size limit. The codebase doesn't currently use multipart // forms (uploads go through HTMX + fetch, which attach // `X-CSRF-Token` on the header path above), so rejecting here is // the explicit boundary. If multipart adoption ever becomes // necessary, register the route with `post_csrf_manual` and have // the handler stream the body through a multipart parser before // calling `validate_token_consuming`. // - `application/json` and others must use the `X-CSRF-Token` // header, anything that can set a custom header can set this one. let content_type = request .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let is_form = content_type.starts_with("application/x-www-form-urlencoded"); if !is_form { let is_multipart = content_type.starts_with("multipart/form-data"); tracing::warn!( path = %path, content_type, is_multipart, "CSRF token missing for authenticated non-form request" ); return crate::error::AppError::Forbidden.into_response(); } // Buffer the body to extract _csrf, then reconstruct the request. // Limit matches the global RequestBodyLimitLayer (1 MB) so that any // form body accepted by the server can have its CSRF token extracted. let (parts, body) = request.into_parts(); let Ok(bytes) = axum::body::to_bytes(body, 1024 * 1024).await else { return (StatusCode::BAD_REQUEST, "Request body too large").into_response(); }; let body_str = String::from_utf8_lossy(&bytes); let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str)); let Some(token) = body_token else { tracing::warn!(path = %path, "CSRF token missing from form body"); return crate::error::AppError::Forbidden.into_response(); }; match validate_token(&session, &token).await { Ok(true) => { // Reconstruct request with the buffered body let request = Request::from_parts(parts, axum::body::Body::from(bytes)); next.run(request).await } Ok(false) => { tracing::warn!(path = %path, "CSRF token mismatch"); (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response() } Err(e) => { tracing::error!(error = ?e, "CSRF validation error"); (StatusCode::INTERNAL_SERVER_ERROR, "CSRF validation error").into_response() } } } /// Mutating routes that are deliberately merged OUTSIDE the finalized /// [`CsrfRouter`] tree (registered with a raw `post(...)` rather than /// `post_csrf(...)`), and so are NOT protected by cookie-CSRF. /// /// Each is safe only because it authenticates with a NON-cookie credential, a /// git Personal Access Token / bearer over the git smart-HTTP wire protocol, /// so a browser can't be tricked into driving it cross-site. Adding a /// cookie-authed mutation to a raw-merged surface (`page_routes`, `git_routes`, /// `sso_routes`, `embed_routes` in `lib.rs`) is a CSRF hole: route it through /// `post_csrf`/the `CsrfRouter` instead. The build-time scan /// `every_raw_mutation_outside_csrf_router_is_justified` fails if a raw mutating /// route appears in those surfaces without a matching entry here. /// /// Entries match by a substring of the registering line (the handler path). #[cfg(test)] const CSRF_CARVE_OUTS: &[(&str, &str)] = &[ ( "smart_http_upload_pack", "git fetch/clone over smart-HTTP; bearer/PAT- or public-repo-authed, never a session cookie", ), ( "smart_http_receive_pack", "git push over smart-HTTP; requires a push-scoped PAT and rejects cookie auth (240a4ca)", ), ]; #[cfg(test)] mod tests { use super::*; /// The bug this pair of functions exists to keep apart. /// /// A Manual-posture handler holds `form.csrf`, the token's value. Feeding /// that to the body parser reads the token as a key with an empty value and /// finds no `_csrf`, so the caller gets `None`, defaults it to "", and every /// vanilla form post answers 403. Only HTMX worked, because it takes the /// header branch above. #[test] fn a_bare_field_value_is_not_a_form_body() { let token = "a".repeat(64); let empty = HeaderMap::new(); assert_eq!( extract_token_from_request(&empty, Some(&token)), None, "the body parser must not find a token in a bare field value" ); assert_eq!( token_from_header_or_field(&empty, Some(&token)), Some(token.clone()), "the field helper must take the value as given" ); assert_eq!( extract_token_from_request(&empty, Some(&format!("login=x&_csrf={token}"))), Some(token), "the body parser must still read a real urlencoded body" ); } #[test] fn the_header_outranks_the_field() { let mut headers = HeaderMap::new(); headers.insert("X-CSRF-Token", "from-header".parse().unwrap()); assert_eq!( token_from_header_or_field(&headers, Some("from-field")), Some("from-header".to_string()) ); assert_eq!(token_from_header_or_field(&HeaderMap::new(), None), None); } #[test] fn test_generate_token() { let token1 = generate_token(); let token2 = generate_token(); // Tokens should be 64 hex characters (32 bytes) assert_eq!(token1.len(), 64); assert_eq!(token2.len(), 64); // Tokens should be different assert_ne!(token1, token2); } #[test] fn test_constant_time_compare() { use crate::helpers::constant_time_compare; assert!(constant_time_compare("abc", "abc")); assert!(!constant_time_compare("abc", "abd")); assert!(!constant_time_compare("abc", "abcd")); assert!(!constant_time_compare("", "a")); } #[test] fn test_generate_token_is_hex() { let token = generate_token(); // Should be valid hex assert!(token.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn test_extract_token_from_header() { let mut headers = HeaderMap::new(); headers.insert("X-CSRF-Token", "abc123".parse().unwrap()); let token = extract_token_from_request(&headers, None); assert_eq!(token.as_deref(), Some("abc123")); } #[test] fn test_extract_token_from_form_body() { let headers = HeaderMap::new(); let body = "name=value&_csrf=mytoken123&other=data"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("mytoken123")); } #[test] fn test_extract_token_missing() { let headers = HeaderMap::new(); let token = extract_token_from_request(&headers, None); assert!(token.is_none()); } #[test] fn test_generate_token_unique_across_many() { let tokens: Vec = (0..100).map(|_| generate_token()).collect(); let unique: std::collections::HashSet<&String> = tokens.iter().collect(); assert_eq!(unique.len(), 100, "all 100 tokens should be unique"); } #[test] fn test_generate_token_correct_byte_length() { let token = generate_token(); let bytes = hex::decode(&token).expect("token should be valid hex"); assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH); } #[test] fn test_extract_token_header_takes_priority_over_body() { let mut headers = HeaderMap::new(); headers.insert("X-CSRF-Token", "header_token".parse().unwrap()); let body = "_csrf=body_token"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("header_token")); } #[test] fn test_extract_token_from_body_url_encoded() { let headers = HeaderMap::new(); let body = "_csrf=token%20with%20spaces&other=val"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("token with spaces")); } #[test] fn test_extract_token_csrf_at_start_of_body() { let headers = HeaderMap::new(); let body = "_csrf=firstfield&name=value"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("firstfield")); } #[test] fn test_extract_token_csrf_at_end_of_body() { let headers = HeaderMap::new(); let body = "name=value&_csrf=lastfield"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("lastfield")); } #[test] fn test_extract_token_empty_body() { let headers = HeaderMap::new(); let token = extract_token_from_request(&headers, Some("")); assert!(token.is_none()); } #[test] fn test_extract_token_body_without_csrf_field() { let headers = HeaderMap::new(); let body = "name=value&other=data"; let token = extract_token_from_request(&headers, Some(body)); assert!(token.is_none()); } #[test] fn test_extract_token_csrf_prefix_mismatch() { let headers = HeaderMap::new(); // Field named "_csrfx" should NOT match "_csrf=" let body = "_csrfx=notreal"; let token = extract_token_from_request(&headers, Some(body)); assert!(token.is_none()); } #[test] fn test_extract_token_empty_csrf_value() { let headers = HeaderMap::new(); let body = "_csrf=&other=val"; let token = extract_token_from_request(&headers, Some(body)); assert_eq!(token.as_deref(), Some("")); } #[test] fn test_constant_time_compare_empty_strings() { use crate::helpers::constant_time_compare; assert!(constant_time_compare("", "")); } #[test] fn test_constant_time_compare_near_miss() { use crate::helpers::constant_time_compare; let token = generate_token(); // Flip last character let mut tampered = token.clone(); let last = tampered.pop().unwrap(); tampered.push(if last == '0' { '1' } else { '0' }); assert!(!constant_time_compare(&token, &tampered)); } #[test] fn csrf_manually_validated_marker_is_zero_sized() { assert_eq!(std::mem::size_of::(), 0); } #[test] fn csrf_posture_is_copyable_and_carries_reason() { let p = CsrfPosture::Skip("webhook: stripe signature"); let copy = p; match copy { CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"), _ => panic!("variant mismatch"), } } #[test] fn test_constant_time_compare_truncated() { use crate::helpers::constant_time_compare; let token = generate_token(); let truncated = &token[..token.len() - 1]; assert!(!constant_time_compare(&token, truncated)); } #[test] fn url_host_strips_scheme_port_and_path() { assert_eq!( url_host("https://makenot.work").as_deref(), Some("makenot.work") ); assert_eq!( url_host("https://makenot.work:8443").as_deref(), Some("makenot.work") ); assert_eq!( url_host("https://makenot.work/forgot-password?x=1").as_deref(), Some("makenot.work") ); assert_eq!( url_host("http://EXAMPLE.com").as_deref(), Some("example.com") ); assert_eq!(url_host("https://[::1]:8080/p").as_deref(), Some("[::1]")); } #[test] fn url_host_rejects_opaque_and_schemeless() { assert_eq!(url_host("null"), None); assert_eq!(url_host("makenot.work"), None); // no scheme => unusable signal assert_eq!(url_host("https://"), None); } fn headers(pairs: &[(&str, &str)]) -> axum::http::HeaderMap { let mut h = axum::http::HeaderMap::new(); for (k, v) in pairs { h.insert( axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(), axum::http::HeaderValue::from_str(v).unwrap(), ); } h } #[test] fn cross_site_sec_fetch_site_is_authoritative() { assert!(is_cross_site(&headers(&[("sec-fetch-site", "cross-site")]))); assert!(!is_cross_site(&headers(&[( "sec-fetch-site", "same-origin" )]))); assert!(!is_cross_site(&headers(&[("sec-fetch-site", "same-site")]))); assert!(!is_cross_site(&headers(&[("sec-fetch-site", "none")]))); // case-insensitive assert!(is_cross_site(&headers(&[("sec-fetch-site", "Cross-Site")]))); } #[test] fn cross_site_origin_fallback_compares_host() { // Sec-Fetch-Site absent => fall back to Origin vs Host. assert!(is_cross_site(&headers(&[ ("host", "makenot.work"), ("origin", "https://evil.example"), ]))); assert!(!is_cross_site(&headers(&[ ("host", "makenot.work"), ("origin", "https://makenot.work"), ]))); // port differences don't matter (same host) assert!(!is_cross_site(&headers(&[ ("host", "makenot.work:443"), ("origin", "https://makenot.work"), ]))); // Referer used only when Origin is absent assert!(is_cross_site(&headers(&[ ("host", "makenot.work"), ("referer", "https://evil.example/x"), ]))); } #[test] fn cross_site_no_signal_is_allowed() { // Header-less client (server-to-server, CLI): nothing to compare => allow. assert!(!is_cross_site(&headers(&[("host", "makenot.work")]))); assert!(!is_cross_site(&headers(&[]))); // Opaque/unparseable Origin yields no host => allow (positive-only policy). assert!(!is_cross_site(&headers(&[ ("host", "makenot.work"), ("origin", "null"), ]))); } // ── CSRF carve-out manifest: every cookie-authable mutation is sealed ── /// True if `verb(` occurs in `line` as a free-function call (axum's /// `post`/`put`/`patch`/`delete` method routers) rather than a method call /// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(`, the `(` /// must immediately follow the verb, which excludes `post_csrf`). fn has_raw_method_router(line: &str, verb: &str) -> bool { let needle = format!("{verb}("); let mut from = 0; while let Some(rel) = line[from..].find(&needle) { let at = from + rel; let prev = line[..at].chars().next_back(); // Reject `.post(` (method call) and `xpost(` (identifier suffix like // `post_csrf` can't reach here, `(` follows the verb directly). if !matches!(prev, Some(c) if c == '.' || c.is_alphanumeric() || c == '_') { return true; } from = at + needle.len(); } false } /// Build-time seal (the D2 constructive fix): the router surfaces merged raw /// in `lib.rs` outside the finalized `CsrfRouter` must not register any /// mutating route that isn't a declared, justified [`CSRF_CARVE_OUTS`] entry. /// A future cookie-authed `post(...)` added to a carve-out surface, instead /// of `post_csrf(...)`, fails here rather than shipping a silent CSRF hole. #[test] fn every_raw_mutation_outside_csrf_router_is_justified() { use std::path::Path; fn scan(path: &Path, contents: &str, offenders: &mut Vec) { for (i, line) in contents.lines().enumerate() { let trimmed = line.trim_start(); if trimmed.starts_with("//") || trimmed.starts_with('*') { continue; } let is_mutation = ["post", "put", "patch", "delete"] .iter() .any(|v| has_raw_method_router(line, v)); if is_mutation && !CSRF_CARVE_OUTS .iter() .any(|(handler, _)| line.contains(handler)) { offenders.push(format!("{}:{}: {}", path.display(), i + 1, trimmed)); } } } fn walk(dir: &Path, offenders: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, offenders); } else if path.extension().is_some_and(|e| e == "rs") && let Ok(contents) = std::fs::read_to_string(&path) { scan(&path, &contents, offenders); } } } let base = Path::new(env!("CARGO_MANIFEST_DIR")); // The surfaces merged raw in lib.rs, outside `.finalize()`. let surfaces = ["src/routes/pages", "src/routes/git", "src/routes/embed"]; let mut offenders = Vec::new(); for s in surfaces { walk(&base.join(s), &mut offenders); } // sso.rs is a single file, not a directory. let sso = base.join("src/routes/sso.rs"); if let Ok(contents) = std::fs::read_to_string(&sso) { scan(&sso, &contents, &mut offenders); } assert!( offenders.is_empty(), "Found cookie-authable mutation(s) merged OUTSIDE the CsrfRouter tree with no \ justified CSRF_CARVE_OUTS entry. Route these through post_csrf/the CsrfRouter, \ or (if genuinely non-cookie-authed) add a documented CSRF_CARVE_OUTS entry:\n{}", offenders.join("\n") ); } }