Skip to main content

max / makenotwork

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