Skip to main content

max / makenotwork

45.6 KB · 1185 lines History Blame Raw
1 //! CSRF (Cross-Site Request Forgery) protection
2 //!
3 //! Uses the synchronizer token pattern:
4 //! 1. Generate random token on session start
5 //! 2. Include token in forms/meta tag
6 //! 3. Validate token on state-changing requests
7
8 use axum::{
9 Router,
10 extract::{FromRequestParts, Request},
11 handler::Handler,
12 http::{StatusCode, header::HeaderMap, request::Parts},
13 middleware::{Next, from_fn},
14 response::{IntoResponse, Response},
15 routing::{MethodRouter, delete, patch, post, put},
16 };
17 use rand::Rng;
18 use std::collections::BTreeMap;
19 use std::sync::{LazyLock, Mutex};
20 use tower_sessions::Session;
21
22 use crate::error::{AppError, ResultExt};
23
24 // --- Route manifest ------------------------------------------------------
25 //
26 // Every mutating route is registered through `CsrfRouter` (the structural
27 // seal), so route registration is the one place that sees every state-changing
28 // path and its declared posture. We harvest that into a process-global manifest
29 // as a side effect of registration. It exists for the router-coverage test
30 // (`tests/workflows/csrf_coverage.rs`), which asserts the whole-router CSRF
31 // invariant in one assertion instead of relying on per-route tests or an audit
32 // to notice a route that drifted. Populated after `build_app` has run once.
33
34 /// Posture kind recorded in the manifest, reason-free and `'static`-free so it
35 /// can live in a process-global. Derived from [`CsrfPosture`].
36 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
37 pub enum ManifestPosture {
38 Auto,
39 Manual,
40 Skip,
41 }
42
43 /// One mutating route's declared CSRF posture, harvested at registration time.
44 #[derive(Clone, Debug)]
45 pub struct CsrfRouteEntry {
46 pub path: String,
47 pub posture: ManifestPosture,
48 /// Documented justification for Manual/Skip; `None` for Auto.
49 pub reason: Option<&'static str>,
50 }
51
52 /// Keyed by path: a path has exactly one posture (multi-method routes share it,
53 /// and Axum forbids registering a path twice). Re-registration across
54 /// `build_app` rebuilds (every test harness builds a fresh app) is idempotent,
55 /// the same key overwrites with identical data.
56 static CSRF_MANIFEST: LazyLock<Mutex<BTreeMap<String, CsrfRouteEntry>>> =
57 LazyLock::new(|| Mutex::new(BTreeMap::new()));
58
59 fn record_route(path: &str, posture: CsrfPosture) {
60 let (posture, reason) = match posture {
61 CsrfPosture::Auto => (ManifestPosture::Auto, None),
62 CsrfPosture::Manual(r) => (ManifestPosture::Manual, Some(r)),
63 CsrfPosture::Skip(r) => (ManifestPosture::Skip, Some(r)),
64 };
65 CSRF_MANIFEST.lock().unwrap().insert(
66 path.to_string(),
67 CsrfRouteEntry {
68 path: path.to_string(),
69 posture,
70 reason,
71 },
72 );
73 }
74
75 /// Snapshot of every mutating route registered through [`CsrfRouter`], with its
76 /// declared CSRF posture. Populated as a side effect of route registration, so
77 /// it is only complete after `build_app` has run at least once in the process.
78 pub fn route_manifest() -> Vec<CsrfRouteEntry> {
79 CSRF_MANIFEST.lock().unwrap().values().cloned().collect()
80 }
81
82 /// Session key for storing CSRF token
83 pub const CSRF_SESSION_KEY: &str = "csrf_token";
84
85 /// CSRF token length in bytes (32 bytes = 256 bits)
86 const CSRF_TOKEN_LENGTH: usize = 32;
87
88 /// Generate a new CSRF token
89 pub fn generate_token() -> String {
90 let mut token = [0u8; CSRF_TOKEN_LENGTH];
91 rand::rng().fill_bytes(&mut token);
92 hex::encode(token)
93 }
94
95 /// Get or create a CSRF token for the session.
96 ///
97 /// `tower-sessions`' `insert` is last-write-wins, so two concurrent first
98 /// requests (e.g. the user opens two tabs while not yet having a token)
99 /// can each generate a fresh token and clobber each other at store-save time,
100 /// the losing tab's rendered token is then stale and its first mutation gets a
101 /// 403, after which a reload picks up the winning token. This is a rare,
102 /// self-correcting edge for the brief window before a session has any token; it
103 /// cannot be reconciled in-process because each request holds an independent
104 /// session load (see the note in the body).
105 pub async fn get_or_create_token(session: &Session) -> Result<String, AppError> {
106 if let Some(token) = session
107 .get::<String>(CSRF_SESSION_KEY)
108 .await
109 .context("session error")?
110 {
111 return Ok(token);
112 }
113
114 let candidate = generate_token();
115 session
116 .insert(CSRF_SESSION_KEY, &candidate)
117 .await
118 .context("session insert")?;
119
120 // Return the token we just inserted. (A prior version re-read the key here,
121 // claiming to reconcile a concurrent insert, but two concurrent
122 // first-requests sharing a cookie each hold an independent session load, so
123 // the re-read only ever observes THIS request's own insert and returned
124 // `candidate` unconditionally. The store reconciles the tabs last-writer-wins
125 // at save time; a losing tab may hit one 403 and retry. The re-read was a
126 // no-op, so it's gone.)
127 Ok(candidate)
128 }
129
130 /// Validate a CSRF token against the session token
131 pub async fn validate_token(session: &Session, provided_token: &str) -> Result<bool, AppError> {
132 let session_token: Option<String> = session
133 .get(CSRF_SESSION_KEY)
134 .await
135 .context("session error")?;
136
137 match session_token {
138 Some(token) => Ok(crate::helpers::constant_time_compare(
139 &token,
140 provided_token,
141 )),
142 None => Ok(false),
143 }
144 }
145
146 /// Token for a Manual-posture handler, which has an already-parsed form rather
147 /// than a raw body: the `X-CSRF-Token` header first, then the deserialized
148 /// `_csrf` field.
149 ///
150 /// Separate from [`extract_token_from_request`] because that one takes the raw
151 /// urlencoded *body* and searches it for a `_csrf=` key. Handing it the field's
152 /// *value* instead looks right and silently yields `None`: the parser reads the
153 /// token itself as a key with an empty value, finds no `_csrf`, and the caller's
154 /// `unwrap_or_default()` turns that into an empty token that can never validate.
155 ///
156 /// Both Manual call sites did exactly that, so `POST /login` and the vanilla tip
157 /// form answered 403 to every request that did not come from HTMX. HTMX sends
158 /// the header and takes the first branch, which is why the whole site worked and
159 /// only the no-JS form path was dead. Found 2026-08-07 driving the landing
160 /// carousel capture, which posts a plain form on purpose.
161 pub fn token_from_header_or_field(headers: &HeaderMap, field: Option<&str>) -> Option<String> {
162 if let Some(token) = headers
163 .get("X-CSRF-Token")
164 .and_then(|v| v.to_str().ok())
165 .map(std::string::ToString::to_string)
166 {
167 return Some(token);
168 }
169 field.map(std::string::ToString::to_string)
170 }
171
172 /// Extract CSRF token from request (header or the raw form-encoded **body**).
173 ///
174 /// `body` is the whole urlencoded body, not a single field. A handler holding a
175 /// deserialized form wants [`token_from_header_or_field`] instead.
176 pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Option<String> {
177 // Try the X-CSRF-Token header (used by HTMX)
178 if let Some(token) = headers
179 .get("X-CSRF-Token")
180 .and_then(|v| v.to_str().ok())
181 .map(std::string::ToString::to_string)
182 {
183 return Some(token);
184 }
185
186 // Fall back to the `_csrf` field in form-encoded body (vanilla HTML
187 // forms). We use a proper urlencoded parser instead of `split('&')`
188 // so a textarea containing `&_csrf=attacker-token` can't sneak past
189 // a later field with the wrong value, the parser respects field
190 // ordering and won't conflate textarea content with form fields
191 // because the form encoder percent-encodes `&` inside text values.
192 if let Some(body_str) = body {
193 for (key, value) in url::form_urlencoded::parse(body_str.as_bytes()) {
194 if key == "_csrf" {
195 return Some(value.into_owned());
196 }
197 }
198 }
199
200 None
201 }
202
203 /// Extractor for CSRF token from session
204 pub struct CsrfToken(pub String);
205
206 impl<S> FromRequestParts<S> for CsrfToken
207 where
208 S: Send + Sync,
209 {
210 type Rejection = AppError;
211
212 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
213 let session = parts
214 .extensions
215 .get::<Session>()
216 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
217
218 let token = get_or_create_token(session).await?;
219 Ok(CsrfToken(token))
220 }
221 }
222
223 /// Per-route CSRF posture, declared at the route registration site via the
224 /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures
225 /// so the choice (and its reason) lives next to the route, not in a sibling
226 /// allowlist file. The structural guarantee comes from `CsrfRouter` only
227 /// accepting `PostureMethodRouter` values; the posture is also carried on the
228 /// `PostureMethodRouter` so `CsrfRouter::route` can harvest it into the
229 /// [`route_manifest`] consumed by the router-coverage test.
230 #[derive(Clone, Copy, Debug)]
231 pub enum CsrfPosture {
232 /// Standard validation layer runs (header or form `_csrf`).
233 Auto,
234 /// Handler validates the token itself and proves it with the
235 /// `CsrfManuallyValidated` witness. Reason documents why the
236 /// standard layer can't apply (e.g. "multipart upload").
237 Manual(&'static str),
238 /// No CSRF check applies. Reason documents why (webhook signature,
239 /// signed link, pre-auth, etc.).
240 Skip(&'static str),
241 }
242
243 /// Witness type proving a handler ran the standard CSRF validation path.
244 /// The only public way to obtain one is `validate_token_consuming`, which
245 /// performs the check. The private field with a private-module constructor
246 /// makes the value un-fabricable from outside this module, `Default`,
247 /// struct-literal, and `Clone` are all impossible for callers.
248 pub use sealed::CsrfManuallyValidated;
249
250 mod sealed {
251 pub struct CsrfManuallyValidated {
252 _private: (),
253 }
254
255 pub(super) fn make_validated() -> CsrfManuallyValidated {
256 CsrfManuallyValidated { _private: () }
257 }
258 }
259
260 /// Validate a token and return a sealed witness on success. Used by
261 /// handlers registered with `post_csrf_manual` (and method variants)
262 /// that need to validate inside the handler body, typically because the
263 /// global middleware can't read the token for this content type (e.g.
264 /// multipart) or because validation is conditional on request state.
265 pub async fn validate_token_consuming(
266 session: &Session,
267 provided_token: &str,
268 ) -> Result<CsrfManuallyValidated, AppError> {
269 if validate_token(session, provided_token).await? {
270 Ok(sealed::make_validated())
271 } else {
272 Err(AppError::Forbidden)
273 }
274 }
275
276 // Manual-posture runtime assertion (dev/test only): attempted via a tokio
277 // task-local flag set in `validate_token_consuming` and checked in a per-
278 // route layer. Backed out 2026-05-27, false-positive density was too high:
279 // rendered error pages return 200, rate-limit and form-extraction
280 // short-circuit before the handler, and the audit explicitly marked this
281 // follow-up as "not blocking, only matters if Manual grows beyond one
282 // route". Compile-time discipline (the `CsrfManuallyValidated` witness type
283 // bound as `_validated`) stays the convention.
284
285 /// Wrap a method-router with the Auto-posture validation layer.
286 /// Runs `validate_auto` on every request that reaches the route.
287 fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S>
288 where
289 S: Clone + Send + Sync + 'static,
290 {
291 method_router.layer(from_fn(|req: Request, next: Next| async move {
292 let path = req.uri().path().to_string();
293 validate_auto(req, next, &path).await
294 }))
295 }
296
297 /// A `MethodRouter` that has been through one of the CSRF helpers. Field
298 /// is private and constructible only inside this module, so
299 /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`;
300 /// route files have to use the helpers, by construction.
301 pub use posture_router::PostureMethodRouter;
302
303 mod posture_router {
304 use super::{CsrfPosture, MethodRouter};
305
306 pub struct PostureMethodRouter<S = ()> {
307 inner: MethodRouter<S>,
308 posture: CsrfPosture,
309 }
310
311 impl<S> PostureMethodRouter<S>
312 where
313 S: Clone + Send + Sync + 'static,
314 {
315 pub(super) fn new(inner: MethodRouter<S>, posture: CsrfPosture) -> Self {
316 Self { inner, posture }
317 }
318
319 pub(super) fn into_inner(self) -> MethodRouter<S> {
320 self.inner
321 }
322
323 pub(super) fn posture(&self) -> CsrfPosture {
324 self.posture
325 }
326
327 /// Attach an additional tower layer (e.g. a rate limiter) to the
328 /// underlying method router. Returns `Self` so callers don't lose
329 /// the posture stamp.
330 #[must_use]
331 pub fn layer<L>(self, layer: L) -> Self
332 where
333 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
334 L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
335 <L::Service as tower::Service<axum::extract::Request>>::Response:
336 axum::response::IntoResponse + 'static,
337 <L::Service as tower::Service<axum::extract::Request>>::Error:
338 Into<std::convert::Infallible> + 'static,
339 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
340 {
341 Self {
342 inner: self.inner.layer(layer),
343 posture: self.posture,
344 }
345 }
346 }
347 }
348
349 macro_rules! csrf_auto_helper {
350 ($name:ident, $axum_fn:ident) => {
351 pub fn $name<H, T, S>(handler: H) -> PostureMethodRouter<S>
352 where
353 H: Handler<T, S>,
354 T: 'static,
355 S: Clone + Send + Sync + 'static,
356 {
357 posture_router::PostureMethodRouter::new(
358 attach_auto_layer($axum_fn(handler)),
359 CsrfPosture::Auto,
360 )
361 }
362 };
363 }
364
365 macro_rules! csrf_passthrough_helper {
366 ($name:ident, $axum_fn:ident, $variant:ident) => {
367 pub fn $name<H, T, S>(reason: &'static str, handler: H) -> PostureMethodRouter<S>
368 where
369 H: Handler<T, S>,
370 T: 'static,
371 S: Clone + Send + Sync + 'static,
372 {
373 posture_router::PostureMethodRouter::new(
374 $axum_fn(handler),
375 CsrfPosture::$variant(reason),
376 )
377 }
378 };
379 }
380
381 // Auto posture: standard CSRF validation (header or form `_csrf`).
382 csrf_auto_helper!(post_csrf, post);
383 csrf_auto_helper!(put_csrf, put);
384 csrf_auto_helper!(patch_csrf, patch);
385 csrf_auto_helper!(delete_csrf, delete);
386
387 // Manual posture: handler validates via `validate_token_consuming`.
388 csrf_passthrough_helper!(post_csrf_manual, post, Manual);
389 csrf_passthrough_helper!(put_csrf_manual, put, Manual);
390 csrf_passthrough_helper!(patch_csrf_manual, patch, Manual);
391 csrf_passthrough_helper!(delete_csrf_manual, delete, Manual);
392
393 // Skip posture: no CSRF check. Reason documents why.
394 csrf_passthrough_helper!(post_csrf_skip, post, Skip);
395 csrf_passthrough_helper!(put_csrf_skip, put, Skip);
396 csrf_passthrough_helper!(patch_csrf_skip, patch, Skip);
397 csrf_passthrough_helper!(delete_csrf_skip, delete, Skip);
398
399 // --- Wrappers for multi-method routes ------------------------------------
400 //
401 // A handful of routes register multiple HTTP methods on one path
402 // (e.g. `get(list).post(create)`). The handler-taking helpers above can't
403 // compose with these because the chain is already a `MethodRouter`. These
404 // wrappers take a pre-built `MethodRouter` and stamp it as a
405 // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected, the
406 // Auto validation layer only intercepts state-changing methods at the
407 // per-route level because that's what the helper attached to.
408
409 /// Wrap a multi-method chain with the Auto-posture validation layer.
410 pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S>
411 where
412 S: Clone + Send + Sync + 'static,
413 {
414 posture_router::PostureMethodRouter::new(attach_auto_layer(method_router), CsrfPosture::Auto)
415 }
416
417 /// Stamp a multi-method chain as Manual, handler is responsible for
418 /// calling `validate_token_consuming`.
419 pub fn with_csrf_manual<S>(
420 reason: &'static str,
421 method_router: MethodRouter<S>,
422 ) -> PostureMethodRouter<S>
423 where
424 S: Clone + Send + Sync + 'static,
425 {
426 posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Manual(reason))
427 }
428
429 /// Stamp a multi-method chain as Skip, no CSRF check applies.
430 pub fn with_csrf_skip<S>(
431 reason: &'static str,
432 method_router: MethodRouter<S>,
433 ) -> PostureMethodRouter<S>
434 where
435 S: Clone + Send + Sync + 'static,
436 {
437 posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Skip(reason))
438 }
439
440 // --- Origin gate: posture-independent pre-auth seal ----------------------
441 //
442 // Applied once to the whole `CsrfRouter` tree in `finalize`, so it covers
443 // every registered route, Auto, Manual, and Skip alike, and runs before any
444 // per-route posture. It closes the pre-auth forgery vector (CHRONIC A'): the
445 // per-route `validate_auto` deliberately does NOT require a token from a
446 // logged-out caller (the public form path relies on the anonymous-session
447 // token), so without this gate a cross-site forged POST to a public form such
448 // as `/forgot-password` would reach the handler.
449 //
450 // Policy: reject only when the request is *positively* identified as
451 // cross-site. A request carrying no origin signal at all is allowed through,
452 // every modern browser sends `Sec-Fetch-Site`, so the no-signal case is
453 // non-browser traffic (Stripe webhooks, OAuth callbacks, mnw-cli, curl) that
454 // cannot be driven cross-site from a victim's browser. This keeps server-to-
455 // server and CLI clients working while sealing the browser forgery path.
456
457 fn is_mutating(method: &axum::http::Method) -> bool {
458 matches!(
459 *method,
460 axum::http::Method::POST
461 | axum::http::Method::PUT
462 | axum::http::Method::PATCH
463 | axum::http::Method::DELETE
464 )
465 }
466
467 /// Host (no scheme, no port, lowercased) from an `Origin`/`Referer` value.
468 /// `None` for opaque origins (`"null"`), scheme-less values, or anything
469 /// unparseable, callers treat `None` as "no usable signal" (allow).
470 fn url_host(value: &str) -> Option<String> {
471 let rest = value
472 .strip_prefix("https://")
473 .or_else(|| value.strip_prefix("http://"))?;
474 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
475 // Drop any userinfo (defensive; Origin never carries it).
476 let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
477 Some(strip_port(authority)).filter(|h| !h.is_empty())
478 }
479
480 /// The request's own host from the `Host` header, port stripped, lowercased.
481 fn request_host(headers: &axum::http::HeaderMap) -> Option<String> {
482 let raw = headers.get(axum::http::header::HOST)?.to_str().ok()?;
483 Some(strip_port(raw)).filter(|h| !h.is_empty())
484 }
485
486 /// Strip a trailing `:port`, handle bracketed IPv6 literals, and lowercase.
487 fn strip_port(authority: &str) -> String {
488 let host = if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) {
489 // `[::1]:8080` -> `[::1]`
490 &authority[..end + 2]
491 } else {
492 authority.split(':').next().unwrap_or(authority)
493 };
494 host.to_ascii_lowercase()
495 }
496
497 /// Returns true only when the request is *positively* identified as cross-site.
498 /// Absent or ambiguous signals return false (allow). See [`origin_gate`].
499 fn is_cross_site(headers: &axum::http::HeaderMap) -> bool {
500 // 1. Sec-Fetch-Site (sent by every modern browser) is authoritative.
501 if let Some(sfs) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
502 // same-origin | same-site | none (user-initiated) => not cross-site.
503 return sfs.eq_ignore_ascii_case("cross-site");
504 }
505 // 2. Header-less clients: a present Origin/Referer host must match Host.
506 // No Host to compare against, or no usable origin host => allow.
507 let Some(host) = request_host(headers) else {
508 return false;
509 };
510 if let Some(origin) = headers
511 .get(axum::http::header::ORIGIN)
512 .and_then(|v| v.to_str().ok())
513 {
514 return url_host(origin).is_some_and(|h| h != host);
515 }
516 if let Some(referer) = headers
517 .get(axum::http::header::REFERER)
518 .and_then(|v| v.to_str().ok())
519 {
520 return url_host(referer).is_some_and(|h| h != host);
521 }
522 false
523 }
524
525 /// Posture-independent origin gate; see the module section comment above.
526 async fn origin_gate(request: Request, next: Next) -> Response {
527 if is_mutating(request.method()) && is_cross_site(request.headers()) {
528 tracing::warn!(
529 path = %request.uri().path(),
530 "CSRF origin gate: cross-site mutation rejected"
531 );
532 return crate::error::AppError::Forbidden.into_response();
533 }
534 next.run(request).await
535 }
536
537 // --- CsrfRouter: structural enforcement ----------------------------------
538 //
539 // `CsrfRouter` is the only way to register a mutation route in this
540 // codebase. Its `route` method takes a `PostureMethodRouter<S>`, whose
541 // constructor is private to this module, so the only producers are the
542 // helpers above. A bare `Router::route(path, post(handler))` cannot
543 // reach a mounted `CsrfRouter` without going through `finalize()` first,
544 // which is only called once in `build_app`.
545
546 pub struct CsrfRouter<S = ()>(Router<S>);
547
548 impl<S> Default for CsrfRouter<S>
549 where
550 S: Clone + Send + Sync + 'static,
551 {
552 fn default() -> Self {
553 Self::new()
554 }
555 }
556
557 impl<S> CsrfRouter<S>
558 where
559 S: Clone + Send + Sync + 'static,
560 {
561 pub fn new() -> Self {
562 Self(Router::new())
563 }
564
565 #[must_use]
566 pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
567 record_route(path, posture.posture());
568 Self(self.0.route(path, posture.into_inner()))
569 }
570
571 /// Register a read-only route (GET / HEAD / OPTIONS). The structural
572 /// guarantee only constrains state-changing methods, so read-only
573 /// `MethodRouter`s pass through unchanged. Calling this with a
574 /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but
575 /// readers can see the intent at the call site, and any mutation
576 /// route registered through `route_get` is a bug visible in review.
577 #[must_use]
578 pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self {
579 Self(self.0.route(path, method_router))
580 }
581
582 #[must_use]
583 pub fn merge(self, other: Self) -> Self {
584 Self(self.0.merge(other.0))
585 }
586
587 #[must_use]
588 pub fn nest(self, path: &str, other: Self) -> Self {
589 Self(self.0.nest(path, other.0))
590 }
591
592 #[must_use]
593 pub fn layer<L>(self, layer: L) -> Self
594 where
595 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
596 L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
597 <L::Service as tower::Service<axum::extract::Request>>::Response: IntoResponse + 'static,
598 <L::Service as tower::Service<axum::extract::Request>>::Error:
599 Into<std::convert::Infallible> + 'static,
600 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
601 {
602 Self(self.0.layer(layer))
603 }
604
605 #[must_use]
606 pub fn route_layer<L>(self, layer: L) -> Self
607 where
608 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
609 L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
610 <L::Service as tower::Service<axum::extract::Request>>::Response: IntoResponse + 'static,
611 <L::Service as tower::Service<axum::extract::Request>>::Error:
612 Into<std::convert::Infallible> + 'static,
613 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
614 {
615 Self(self.0.route_layer(layer))
616 }
617
618 /// Drop the structural envelope and return the underlying `Router<S>`.
619 /// Called once in `build_app` after all mutation routes have been
620 /// registered; downstream code may then attach global layers, mount
621 /// static-file services, and add GET-only routes.
622 ///
623 /// The posture-independent [`origin_gate`] is layered on here so it wraps
624 /// every route registered through THIS `CsrfRouter` (the one non-skippable
625 /// CSRF check covering all postures at once); safe methods pass through, so
626 /// the later-added GET/static routes are unaffected.
627 ///
628 /// Carve-out (ultra-fuzz Run 4): routers merged into the app OUTSIDE the
629 /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this
630 /// `origin_gate`. That is sound because those surfaces are GET-only or
631 /// authenticated by a PAT/bearer rather than a session cookie (so they are
632 /// legitimately CSRF-exempt). The one mutating route among them, git
633 /// `receive-pack` (push), ENFORCES this: `authorize_push` requires a
634 /// push-scoped PAT (`token_push == Some(true)`) and rejects session-cookie
635 /// auth, so a cross-origin cookie POST cannot drive a write (UX-S1, Run 7).
636 /// Any future cookie-authed POST added to one of these surfaces must route
637 /// through a `CsrfRouter`, not be merged raw.
638 pub fn finalize(self) -> Router<S> {
639 self.0.layer(from_fn(origin_gate))
640 }
641 }
642
643 /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body
644 /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
645 /// and by the path-allowlist fallback during the L2 migration.
646 async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
647 // Safe methods (RFC 9110 §9.2.1) are read-only by definition, never
648 // CSRF-check them. This matters for multi-method routes wrapped by
649 // `with_csrf(get(load).post(save))`: a bare GET should not require a
650 // token (and the harness doesn't send one for GETs).
651 if !matches!(
652 *request.method(),
653 axum::http::Method::POST
654 | axum::http::Method::PUT
655 | axum::http::Method::PATCH
656 | axum::http::Method::DELETE
657 ) {
658 return next.run(request).await;
659 }
660
661 // Get session from extensions
662 let session = match request.extensions().get::<Session>() {
663 Some(s) => s.clone(),
664 None => {
665 tracing::warn!("CSRF check failed: no session");
666 return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
667 }
668 };
669
670 // Try header first (HTMX requests)
671 let header_token = request
672 .headers()
673 .get("X-CSRF-Token")
674 .and_then(|v| v.to_str().ok())
675 .map(std::string::ToString::to_string);
676
677 if let Some(ref token) = header_token {
678 return match validate_token(&session, token).await {
679 Ok(true) => next.run(request).await,
680 Ok(false) => {
681 tracing::warn!(path = %path, "CSRF token mismatch");
682 crate::error::AppError::Forbidden.into_response()
683 }
684 Err(e) => {
685 tracing::error!(error = ?e, "CSRF validation error");
686 crate::error::AppError::Internal(anyhow::anyhow!("CSRF validation error"))
687 .into_response()
688 }
689 };
690 }
691
692 // No header token, fall through to the form-body `_csrf` check.
693 //
694 // CHRONIC A' (2026-06-15): this previously skipped validation entirely for
695 // logged-out callers (`if !has_user { return next.run ... }`), which let a
696 // cross-site forged POST to a public form (e.g. `/forgot-password`) reach
697 // the handler. The skip is gone: every mutating request now requires a
698 // valid token regardless of auth state. Legitimate logged-out forms carry
699 // one, `get_or_create_token` stamps the anonymous session on the GET
700 // render and the template embeds `_csrf`. The posture-independent
701 // `origin_gate` (see `finalize`) is the complementary seal for browser
702 // forgery; this token check additionally covers header-less forged clients
703 // that the origin gate intentionally lets through.
704 // We only parse `application/x-www-form-urlencoded`. Other content
705 // types are rejected here:
706 // - `multipart/form-data` is the closest near-miss: it has its own
707 // `_csrf` part but parsing it would mean pulling in a multipart
708 // decoder and buffering the entire upload body, defeating the
709 // upload-size limit. The codebase doesn't currently use multipart
710 // forms (uploads go through HTMX + fetch, which attach
711 // `X-CSRF-Token` on the header path above), so rejecting here is
712 // the explicit boundary. If multipart adoption ever becomes
713 // necessary, register the route with `post_csrf_manual` and have
714 // the handler stream the body through a multipart parser before
715 // calling `validate_token_consuming`.
716 // - `application/json` and others must use the `X-CSRF-Token`
717 // header, anything that can set a custom header can set this one.
718 let content_type = request
719 .headers()
720 .get("content-type")
721 .and_then(|v| v.to_str().ok())
722 .unwrap_or("");
723 let is_form = content_type.starts_with("application/x-www-form-urlencoded");
724
725 if !is_form {
726 let is_multipart = content_type.starts_with("multipart/form-data");
727 tracing::warn!(
728 path = %path,
729 content_type,
730 is_multipart,
731 "CSRF token missing for authenticated non-form request"
732 );
733 return crate::error::AppError::Forbidden.into_response();
734 }
735
736 // Buffer the body to extract _csrf, then reconstruct the request.
737 // Limit matches the global RequestBodyLimitLayer (1 MB) so that any
738 // form body accepted by the server can have its CSRF token extracted.
739 let (parts, body) = request.into_parts();
740 let Ok(bytes) = axum::body::to_bytes(body, 1024 * 1024).await else {
741 return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
742 };
743
744 let body_str = String::from_utf8_lossy(&bytes);
745 let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str));
746
747 let Some(token) = body_token else {
748 tracing::warn!(path = %path, "CSRF token missing from form body");
749 return crate::error::AppError::Forbidden.into_response();
750 };
751
752 match validate_token(&session, &token).await {
753 Ok(true) => {
754 // Reconstruct request with the buffered body
755 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
756 next.run(request).await
757 }
758 Ok(false) => {
759 tracing::warn!(path = %path, "CSRF token mismatch");
760 (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
761 }
762 Err(e) => {
763 tracing::error!(error = ?e, "CSRF validation error");
764 (StatusCode::INTERNAL_SERVER_ERROR, "CSRF validation error").into_response()
765 }
766 }
767 }
768
769 /// Mutating routes that are deliberately merged OUTSIDE the finalized
770 /// [`CsrfRouter`] tree (registered with a raw `post(...)` rather than
771 /// `post_csrf(...)`), and so are NOT protected by cookie-CSRF.
772 ///
773 /// Each is safe only because it authenticates with a NON-cookie credential, a
774 /// git Personal Access Token / bearer over the git smart-HTTP wire protocol,
775 /// so a browser can't be tricked into driving it cross-site. Adding a
776 /// cookie-authed mutation to a raw-merged surface (`page_routes`, `git_routes`,
777 /// `sso_routes`, `embed_routes` in `lib.rs`) is a CSRF hole: route it through
778 /// `post_csrf`/the `CsrfRouter` instead. The build-time scan
779 /// `every_raw_mutation_outside_csrf_router_is_justified` fails if a raw mutating
780 /// route appears in those surfaces without a matching entry here.
781 ///
782 /// Entries match by a substring of the registering line (the handler path).
783 #[cfg(test)]
784 const CSRF_CARVE_OUTS: &[(&str, &str)] = &[
785 (
786 "smart_http_upload_pack",
787 "git fetch/clone over smart-HTTP; bearer/PAT- or public-repo-authed, never a session cookie",
788 ),
789 (
790 "smart_http_receive_pack",
791 "git push over smart-HTTP; requires a push-scoped PAT and rejects cookie auth (240a4ca)",
792 ),
793 ];
794
795 #[cfg(test)]
796 mod tests {
797 use super::*;
798
799 /// The bug this pair of functions exists to keep apart.
800 ///
801 /// A Manual-posture handler holds `form.csrf`, the token's value. Feeding
802 /// that to the body parser reads the token as a key with an empty value and
803 /// finds no `_csrf`, so the caller gets `None`, defaults it to "", and every
804 /// vanilla form post answers 403. Only HTMX worked, because it takes the
805 /// header branch above.
806 #[test]
807 fn a_bare_field_value_is_not_a_form_body() {
808 let token = "a".repeat(64);
809 let empty = HeaderMap::new();
810
811 assert_eq!(
812 extract_token_from_request(&empty, Some(&token)),
813 None,
814 "the body parser must not find a token in a bare field value"
815 );
816 assert_eq!(
817 token_from_header_or_field(&empty, Some(&token)),
818 Some(token.clone()),
819 "the field helper must take the value as given"
820 );
821 assert_eq!(
822 extract_token_from_request(&empty, Some(&format!("login=x&_csrf={token}"))),
823 Some(token),
824 "the body parser must still read a real urlencoded body"
825 );
826 }
827
828 #[test]
829 fn the_header_outranks_the_field() {
830 let mut headers = HeaderMap::new();
831 headers.insert("X-CSRF-Token", "from-header".parse().unwrap());
832 assert_eq!(
833 token_from_header_or_field(&headers, Some("from-field")),
834 Some("from-header".to_string())
835 );
836 assert_eq!(token_from_header_or_field(&HeaderMap::new(), None), None);
837 }
838
839 #[test]
840 fn test_generate_token() {
841 let token1 = generate_token();
842 let token2 = generate_token();
843
844 // Tokens should be 64 hex characters (32 bytes)
845 assert_eq!(token1.len(), 64);
846 assert_eq!(token2.len(), 64);
847
848 // Tokens should be different
849 assert_ne!(token1, token2);
850 }
851
852 #[test]
853 fn test_constant_time_compare() {
854 use crate::helpers::constant_time_compare;
855 assert!(constant_time_compare("abc", "abc"));
856 assert!(!constant_time_compare("abc", "abd"));
857 assert!(!constant_time_compare("abc", "abcd"));
858 assert!(!constant_time_compare("", "a"));
859 }
860
861 #[test]
862 fn test_generate_token_is_hex() {
863 let token = generate_token();
864 // Should be valid hex
865 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
866 }
867
868 #[test]
869 fn test_extract_token_from_header() {
870 let mut headers = HeaderMap::new();
871 headers.insert("X-CSRF-Token", "abc123".parse().unwrap());
872 let token = extract_token_from_request(&headers, None);
873 assert_eq!(token.as_deref(), Some("abc123"));
874 }
875
876 #[test]
877 fn test_extract_token_from_form_body() {
878 let headers = HeaderMap::new();
879 let body = "name=value&_csrf=mytoken123&other=data";
880 let token = extract_token_from_request(&headers, Some(body));
881 assert_eq!(token.as_deref(), Some("mytoken123"));
882 }
883
884 #[test]
885 fn test_extract_token_missing() {
886 let headers = HeaderMap::new();
887 let token = extract_token_from_request(&headers, None);
888 assert!(token.is_none());
889 }
890
891 #[test]
892 fn test_generate_token_unique_across_many() {
893 let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect();
894 let unique: std::collections::HashSet<&String> = tokens.iter().collect();
895 assert_eq!(unique.len(), 100, "all 100 tokens should be unique");
896 }
897
898 #[test]
899 fn test_generate_token_correct_byte_length() {
900 let token = generate_token();
901 let bytes = hex::decode(&token).expect("token should be valid hex");
902 assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH);
903 }
904
905 #[test]
906 fn test_extract_token_header_takes_priority_over_body() {
907 let mut headers = HeaderMap::new();
908 headers.insert("X-CSRF-Token", "header_token".parse().unwrap());
909 let body = "_csrf=body_token";
910 let token = extract_token_from_request(&headers, Some(body));
911 assert_eq!(token.as_deref(), Some("header_token"));
912 }
913
914 #[test]
915 fn test_extract_token_from_body_url_encoded() {
916 let headers = HeaderMap::new();
917 let body = "_csrf=token%20with%20spaces&other=val";
918 let token = extract_token_from_request(&headers, Some(body));
919 assert_eq!(token.as_deref(), Some("token with spaces"));
920 }
921
922 #[test]
923 fn test_extract_token_csrf_at_start_of_body() {
924 let headers = HeaderMap::new();
925 let body = "_csrf=firstfield&name=value";
926 let token = extract_token_from_request(&headers, Some(body));
927 assert_eq!(token.as_deref(), Some("firstfield"));
928 }
929
930 #[test]
931 fn test_extract_token_csrf_at_end_of_body() {
932 let headers = HeaderMap::new();
933 let body = "name=value&_csrf=lastfield";
934 let token = extract_token_from_request(&headers, Some(body));
935 assert_eq!(token.as_deref(), Some("lastfield"));
936 }
937
938 #[test]
939 fn test_extract_token_empty_body() {
940 let headers = HeaderMap::new();
941 let token = extract_token_from_request(&headers, Some(""));
942 assert!(token.is_none());
943 }
944
945 #[test]
946 fn test_extract_token_body_without_csrf_field() {
947 let headers = HeaderMap::new();
948 let body = "name=value&other=data";
949 let token = extract_token_from_request(&headers, Some(body));
950 assert!(token.is_none());
951 }
952
953 #[test]
954 fn test_extract_token_csrf_prefix_mismatch() {
955 let headers = HeaderMap::new();
956 // Field named "_csrfx" should NOT match "_csrf="
957 let body = "_csrfx=notreal";
958 let token = extract_token_from_request(&headers, Some(body));
959 assert!(token.is_none());
960 }
961
962 #[test]
963 fn test_extract_token_empty_csrf_value() {
964 let headers = HeaderMap::new();
965 let body = "_csrf=&other=val";
966 let token = extract_token_from_request(&headers, Some(body));
967 assert_eq!(token.as_deref(), Some(""));
968 }
969
970 #[test]
971 fn test_constant_time_compare_empty_strings() {
972 use crate::helpers::constant_time_compare;
973 assert!(constant_time_compare("", ""));
974 }
975
976 #[test]
977 fn test_constant_time_compare_near_miss() {
978 use crate::helpers::constant_time_compare;
979 let token = generate_token();
980 // Flip last character
981 let mut tampered = token.clone();
982 let last = tampered.pop().unwrap();
983 tampered.push(if last == '0' { '1' } else { '0' });
984 assert!(!constant_time_compare(&token, &tampered));
985 }
986
987 #[test]
988 fn csrf_manually_validated_marker_is_zero_sized() {
989 assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0);
990 }
991
992 #[test]
993 fn csrf_posture_is_copyable_and_carries_reason() {
994 let p = CsrfPosture::Skip("webhook: stripe signature");
995 let copy = p;
996 match copy {
997 CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"),
998 _ => panic!("variant mismatch"),
999 }
1000 }
1001
1002 #[test]
1003 fn test_constant_time_compare_truncated() {
1004 use crate::helpers::constant_time_compare;
1005 let token = generate_token();
1006 let truncated = &token[..token.len() - 1];
1007 assert!(!constant_time_compare(&token, truncated));
1008 }
1009
1010 #[test]
1011 fn url_host_strips_scheme_port_and_path() {
1012 assert_eq!(
1013 url_host("https://makenot.work").as_deref(),
1014 Some("makenot.work")
1015 );
1016 assert_eq!(
1017 url_host("https://makenot.work:8443").as_deref(),
1018 Some("makenot.work")
1019 );
1020 assert_eq!(
1021 url_host("https://makenot.work/forgot-password?x=1").as_deref(),
1022 Some("makenot.work")
1023 );
1024 assert_eq!(
1025 url_host("http://EXAMPLE.com").as_deref(),
1026 Some("example.com")
1027 );
1028 assert_eq!(url_host("https://[::1]:8080/p").as_deref(), Some("[::1]"));
1029 }
1030
1031 #[test]
1032 fn url_host_rejects_opaque_and_schemeless() {
1033 assert_eq!(url_host("null"), None);
1034 assert_eq!(url_host("makenot.work"), None); // no scheme => unusable signal
1035 assert_eq!(url_host("https://"), None);
1036 }
1037
1038 fn headers(pairs: &[(&str, &str)]) -> axum::http::HeaderMap {
1039 let mut h = axum::http::HeaderMap::new();
1040 for (k, v) in pairs {
1041 h.insert(
1042 axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
1043 axum::http::HeaderValue::from_str(v).unwrap(),
1044 );
1045 }
1046 h
1047 }
1048
1049 #[test]
1050 fn cross_site_sec_fetch_site_is_authoritative() {
1051 assert!(is_cross_site(&headers(&[("sec-fetch-site", "cross-site")])));
1052 assert!(!is_cross_site(&headers(&[(
1053 "sec-fetch-site",
1054 "same-origin"
1055 )])));
1056 assert!(!is_cross_site(&headers(&[("sec-fetch-site", "same-site")])));
1057 assert!(!is_cross_site(&headers(&[("sec-fetch-site", "none")])));
1058 // case-insensitive
1059 assert!(is_cross_site(&headers(&[("sec-fetch-site", "Cross-Site")])));
1060 }
1061
1062 #[test]
1063 fn cross_site_origin_fallback_compares_host() {
1064 // Sec-Fetch-Site absent => fall back to Origin vs Host.
1065 assert!(is_cross_site(&headers(&[
1066 ("host", "makenot.work"),
1067 ("origin", "https://evil.example"),
1068 ])));
1069 assert!(!is_cross_site(&headers(&[
1070 ("host", "makenot.work"),
1071 ("origin", "https://makenot.work"),
1072 ])));
1073 // port differences don't matter (same host)
1074 assert!(!is_cross_site(&headers(&[
1075 ("host", "makenot.work:443"),
1076 ("origin", "https://makenot.work"),
1077 ])));
1078 // Referer used only when Origin is absent
1079 assert!(is_cross_site(&headers(&[
1080 ("host", "makenot.work"),
1081 ("referer", "https://evil.example/x"),
1082 ])));
1083 }
1084
1085 #[test]
1086 fn cross_site_no_signal_is_allowed() {
1087 // Header-less client (server-to-server, CLI): nothing to compare => allow.
1088 assert!(!is_cross_site(&headers(&[("host", "makenot.work")])));
1089 assert!(!is_cross_site(&headers(&[])));
1090 // Opaque/unparseable Origin yields no host => allow (positive-only policy).
1091 assert!(!is_cross_site(&headers(&[
1092 ("host", "makenot.work"),
1093 ("origin", "null"),
1094 ])));
1095 }
1096
1097 // ── CSRF carve-out manifest: every cookie-authable mutation is sealed ──
1098
1099 /// True if `verb(` occurs in `line` as a free-function call (axum's
1100 /// `post`/`put`/`patch`/`delete` method routers) rather than a method call
1101 /// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(`, the `(`
1102 /// must immediately follow the verb, which excludes `post_csrf`).
1103 fn has_raw_method_router(line: &str, verb: &str) -> bool {
1104 let needle = format!("{verb}(");
1105 let mut from = 0;
1106 while let Some(rel) = line[from..].find(&needle) {
1107 let at = from + rel;
1108 let prev = line[..at].chars().next_back();
1109 // Reject `.post(` (method call) and `xpost(` (identifier suffix like
1110 // `post_csrf` can't reach here, `(` follows the verb directly).
1111 if !matches!(prev, Some(c) if c == '.' || c.is_alphanumeric() || c == '_') {
1112 return true;
1113 }
1114 from = at + needle.len();
1115 }
1116 false
1117 }
1118
1119 /// Build-time seal (the D2 constructive fix): the router surfaces merged raw
1120 /// in `lib.rs` outside the finalized `CsrfRouter` must not register any
1121 /// mutating route that isn't a declared, justified [`CSRF_CARVE_OUTS`] entry.
1122 /// A future cookie-authed `post(...)` added to a carve-out surface, instead
1123 /// of `post_csrf(...)`, fails here rather than shipping a silent CSRF hole.
1124 #[test]
1125 fn every_raw_mutation_outside_csrf_router_is_justified() {
1126 use std::path::Path;
1127
1128 fn scan(path: &Path, contents: &str, offenders: &mut Vec<String>) {
1129 for (i, line) in contents.lines().enumerate() {
1130 let trimmed = line.trim_start();
1131 if trimmed.starts_with("//") || trimmed.starts_with('*') {
1132 continue;
1133 }
1134 let is_mutation = ["post", "put", "patch", "delete"]
1135 .iter()
1136 .any(|v| has_raw_method_router(line, v));
1137 if is_mutation
1138 && !CSRF_CARVE_OUTS
1139 .iter()
1140 .any(|(handler, _)| line.contains(handler))
1141 {
1142 offenders.push(format!("{}:{}: {}", path.display(), i + 1, trimmed));
1143 }
1144 }
1145 }
1146
1147 fn walk(dir: &Path, offenders: &mut Vec<String>) {
1148 let Ok(entries) = std::fs::read_dir(dir) else {
1149 return;
1150 };
1151 for entry in entries.flatten() {
1152 let path = entry.path();
1153 if path.is_dir() {
1154 walk(&path, offenders);
1155 } else if path.extension().is_some_and(|e| e == "rs")
1156 && let Ok(contents) = std::fs::read_to_string(&path)
1157 {
1158 scan(&path, &contents, offenders);
1159 }
1160 }
1161 }
1162
1163 let base = Path::new(env!("CARGO_MANIFEST_DIR"));
1164 // The surfaces merged raw in lib.rs, outside `.finalize()`.
1165 let surfaces = ["src/routes/pages", "src/routes/git", "src/routes/embed"];
1166 let mut offenders = Vec::new();
1167 for s in surfaces {
1168 walk(&base.join(s), &mut offenders);
1169 }
1170 // sso.rs is a single file, not a directory.
1171 let sso = base.join("src/routes/sso.rs");
1172 if let Ok(contents) = std::fs::read_to_string(&sso) {
1173 scan(&sso, &contents, &mut offenders);
1174 }
1175
1176 assert!(
1177 offenders.is_empty(),
1178 "Found cookie-authable mutation(s) merged OUTSIDE the CsrfRouter tree with no \
1179 justified CSRF_CARVE_OUTS entry. Route these through post_csrf/the CsrfRouter, \
1180 or (if genuinely non-cookie-authed) add a documented CSRF_CARVE_OUTS entry:\n{}",
1181 offenders.join("\n")
1182 );
1183 }
1184 }
1185