Skip to main content

max / makenotwork

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