Skip to main content

max / makenotwork

47.5 KB · 1227 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 /// Mount a service that does its own routing, under one declared posture.
593 ///
594 /// For a sub-tree this router cannot see inside: the description layer's
595 /// adapter (`crate::quasi`) resolves its own state per request and routes
596 /// internally, so it arrives as a service rather than as a set of
597 /// `MethodRouter`s the helpers could wrap one at a time.
598 ///
599 /// **Always Auto, and there is deliberately no posture argument.** A skip
600 /// here would exempt a whole sub-tree at once on one line, which is exactly
601 /// the shape of the mistake the structural seal exists to make impossible.
602 /// A surface that genuinely needs another posture should register its
603 /// routes individually so each one declares and justifies itself.
604 ///
605 /// The validation layer wraps the service rather than anything inside it, so
606 /// a tokenless mutation is refused before the nested router is consulted. A
607 /// path the service does not serve is therefore refused too, which is the
608 /// correct order: whether a route exists is not something an unauthenticated
609 /// caller should learn by probing.
610 ///
611 /// One manifest entry, keyed by the mount path, so the coverage test sees
612 /// the sub-tree as one Auto surface and probes it as one.
613 #[must_use]
614 pub fn nest_service<T>(self, path: &str, service: T) -> Self
615 where
616 T: tower::Service<Request, Error = std::convert::Infallible>
617 + Clone
618 + Send
619 + Sync
620 + 'static,
621 T::Response: IntoResponse + 'static,
622 T::Future: Send + 'static,
623 {
624 record_route(path, CsrfPosture::Auto);
625 let guarded = tower::ServiceBuilder::new()
626 .layer(from_fn(|req: Request, next: Next| async move {
627 let path = req.uri().path().to_string();
628 validate_auto(req, next, &path).await
629 }))
630 .service(service);
631 Self(self.0.nest_service(path, guarded))
632 }
633
634 #[must_use]
635 pub fn layer<L>(self, layer: L) -> Self
636 where
637 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
638 L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
639 <L::Service as tower::Service<axum::extract::Request>>::Response: IntoResponse + 'static,
640 <L::Service as tower::Service<axum::extract::Request>>::Error:
641 Into<std::convert::Infallible> + 'static,
642 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
643 {
644 Self(self.0.layer(layer))
645 }
646
647 #[must_use]
648 pub fn route_layer<L>(self, layer: L) -> Self
649 where
650 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
651 L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
652 <L::Service as tower::Service<axum::extract::Request>>::Response: IntoResponse + 'static,
653 <L::Service as tower::Service<axum::extract::Request>>::Error:
654 Into<std::convert::Infallible> + 'static,
655 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
656 {
657 Self(self.0.route_layer(layer))
658 }
659
660 /// Drop the structural envelope and return the underlying `Router<S>`.
661 /// Called once in `build_app` after all mutation routes have been
662 /// registered; downstream code may then attach global layers, mount
663 /// static-file services, and add GET-only routes.
664 ///
665 /// The posture-independent [`origin_gate`] is layered on here so it wraps
666 /// every route registered through THIS `CsrfRouter` (the one non-skippable
667 /// CSRF check covering all postures at once); safe methods pass through, so
668 /// the later-added GET/static routes are unaffected.
669 ///
670 /// Carve-out (ultra-fuzz Run 4): routers merged into the app OUTSIDE the
671 /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this
672 /// `origin_gate`. That is sound because those surfaces are GET-only or
673 /// authenticated by a PAT/bearer rather than a session cookie (so they are
674 /// legitimately CSRF-exempt). The one mutating route among them, git
675 /// `receive-pack` (push), ENFORCES this: `authorize_push` requires a
676 /// push-scoped PAT (`token_push == Some(true)`) and rejects session-cookie
677 /// auth, so a cross-origin cookie POST cannot drive a write (UX-S1, Run 7).
678 /// Any future cookie-authed POST added to one of these surfaces must route
679 /// through a `CsrfRouter`, not be merged raw.
680 pub fn finalize(self) -> Router<S> {
681 self.0.layer(from_fn(origin_gate))
682 }
683 }
684
685 /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body
686 /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
687 /// and by the path-allowlist fallback during the L2 migration.
688 async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
689 // Safe methods (RFC 9110 §9.2.1) are read-only by definition, never
690 // CSRF-check them. This matters for multi-method routes wrapped by
691 // `with_csrf(get(load).post(save))`: a bare GET should not require a
692 // token (and the harness doesn't send one for GETs).
693 if !matches!(
694 *request.method(),
695 axum::http::Method::POST
696 | axum::http::Method::PUT
697 | axum::http::Method::PATCH
698 | axum::http::Method::DELETE
699 ) {
700 return next.run(request).await;
701 }
702
703 // Get session from extensions
704 let session = match request.extensions().get::<Session>() {
705 Some(s) => s.clone(),
706 None => {
707 tracing::warn!("CSRF check failed: no session");
708 return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
709 }
710 };
711
712 // Try header first (HTMX requests)
713 let header_token = request
714 .headers()
715 .get("X-CSRF-Token")
716 .and_then(|v| v.to_str().ok())
717 .map(std::string::ToString::to_string);
718
719 if let Some(ref token) = header_token {
720 return match validate_token(&session, token).await {
721 Ok(true) => next.run(request).await,
722 Ok(false) => {
723 tracing::warn!(path = %path, "CSRF token mismatch");
724 crate::error::AppError::Forbidden.into_response()
725 }
726 Err(e) => {
727 tracing::error!(error = ?e, "CSRF validation error");
728 crate::error::AppError::Internal(anyhow::anyhow!("CSRF validation error"))
729 .into_response()
730 }
731 };
732 }
733
734 // No header token, fall through to the form-body `_csrf` check.
735 //
736 // CHRONIC A' (2026-06-15): this previously skipped validation entirely for
737 // logged-out callers (`if !has_user { return next.run ... }`), which let a
738 // cross-site forged POST to a public form (e.g. `/forgot-password`) reach
739 // the handler. The skip is gone: every mutating request now requires a
740 // valid token regardless of auth state. Legitimate logged-out forms carry
741 // one, `get_or_create_token` stamps the anonymous session on the GET
742 // render and the template embeds `_csrf`. The posture-independent
743 // `origin_gate` (see `finalize`) is the complementary seal for browser
744 // forgery; this token check additionally covers header-less forged clients
745 // that the origin gate intentionally lets through.
746 // We only parse `application/x-www-form-urlencoded`. Other content
747 // types are rejected here:
748 // - `multipart/form-data` is the closest near-miss: it has its own
749 // `_csrf` part but parsing it would mean pulling in a multipart
750 // decoder and buffering the entire upload body, defeating the
751 // upload-size limit. The codebase doesn't currently use multipart
752 // forms (uploads go through HTMX + fetch, which attach
753 // `X-CSRF-Token` on the header path above), so rejecting here is
754 // the explicit boundary. If multipart adoption ever becomes
755 // necessary, register the route with `post_csrf_manual` and have
756 // the handler stream the body through a multipart parser before
757 // calling `validate_token_consuming`.
758 // - `application/json` and others must use the `X-CSRF-Token`
759 // header, anything that can set a custom header can set this one.
760 let content_type = request
761 .headers()
762 .get("content-type")
763 .and_then(|v| v.to_str().ok())
764 .unwrap_or("");
765 let is_form = content_type.starts_with("application/x-www-form-urlencoded");
766
767 if !is_form {
768 let is_multipart = content_type.starts_with("multipart/form-data");
769 tracing::warn!(
770 path = %path,
771 content_type,
772 is_multipart,
773 "CSRF token missing for authenticated non-form request"
774 );
775 return crate::error::AppError::Forbidden.into_response();
776 }
777
778 // Buffer the body to extract _csrf, then reconstruct the request.
779 // Limit matches the global RequestBodyLimitLayer (1 MB) so that any
780 // form body accepted by the server can have its CSRF token extracted.
781 let (parts, body) = request.into_parts();
782 let Ok(bytes) = axum::body::to_bytes(body, 1024 * 1024).await else {
783 return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
784 };
785
786 let body_str = String::from_utf8_lossy(&bytes);
787 let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str));
788
789 let Some(token) = body_token else {
790 tracing::warn!(path = %path, "CSRF token missing from form body");
791 return crate::error::AppError::Forbidden.into_response();
792 };
793
794 match validate_token(&session, &token).await {
795 Ok(true) => {
796 // Reconstruct request with the buffered body
797 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
798 next.run(request).await
799 }
800 Ok(false) => {
801 tracing::warn!(path = %path, "CSRF token mismatch");
802 (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
803 }
804 Err(e) => {
805 tracing::error!(error = ?e, "CSRF validation error");
806 (StatusCode::INTERNAL_SERVER_ERROR, "CSRF validation error").into_response()
807 }
808 }
809 }
810
811 /// Mutating routes that are deliberately merged OUTSIDE the finalized
812 /// [`CsrfRouter`] tree (registered with a raw `post(...)` rather than
813 /// `post_csrf(...)`), and so are NOT protected by cookie-CSRF.
814 ///
815 /// Each is safe only because it authenticates with a NON-cookie credential, a
816 /// git Personal Access Token / bearer over the git smart-HTTP wire protocol,
817 /// so a browser can't be tricked into driving it cross-site. Adding a
818 /// cookie-authed mutation to a raw-merged surface (`page_routes`, `git_routes`,
819 /// `sso_routes`, `embed_routes` in `lib.rs`) is a CSRF hole: route it through
820 /// `post_csrf`/the `CsrfRouter` instead. The build-time scan
821 /// `every_raw_mutation_outside_csrf_router_is_justified` fails if a raw mutating
822 /// route appears in those surfaces without a matching entry here.
823 ///
824 /// Entries match by a substring of the registering line (the handler path).
825 #[cfg(test)]
826 const CSRF_CARVE_OUTS: &[(&str, &str)] = &[
827 (
828 "smart_http_upload_pack",
829 "git fetch/clone over smart-HTTP; bearer/PAT- or public-repo-authed, never a session cookie",
830 ),
831 (
832 "smart_http_receive_pack",
833 "git push over smart-HTTP; requires a push-scoped PAT and rejects cookie auth (240a4ca)",
834 ),
835 ];
836
837 #[cfg(test)]
838 mod tests {
839 use super::*;
840
841 /// The bug this pair of functions exists to keep apart.
842 ///
843 /// A Manual-posture handler holds `form.csrf`, the token's value. Feeding
844 /// that to the body parser reads the token as a key with an empty value and
845 /// finds no `_csrf`, so the caller gets `None`, defaults it to "", and every
846 /// vanilla form post answers 403. Only HTMX worked, because it takes the
847 /// header branch above.
848 #[test]
849 fn a_bare_field_value_is_not_a_form_body() {
850 let token = "a".repeat(64);
851 let empty = HeaderMap::new();
852
853 assert_eq!(
854 extract_token_from_request(&empty, Some(&token)),
855 None,
856 "the body parser must not find a token in a bare field value"
857 );
858 assert_eq!(
859 token_from_header_or_field(&empty, Some(&token)),
860 Some(token.clone()),
861 "the field helper must take the value as given"
862 );
863 assert_eq!(
864 extract_token_from_request(&empty, Some(&format!("login=x&_csrf={token}"))),
865 Some(token),
866 "the body parser must still read a real urlencoded body"
867 );
868 }
869
870 #[test]
871 fn the_header_outranks_the_field() {
872 let mut headers = HeaderMap::new();
873 headers.insert("X-CSRF-Token", "from-header".parse().unwrap());
874 assert_eq!(
875 token_from_header_or_field(&headers, Some("from-field")),
876 Some("from-header".to_string())
877 );
878 assert_eq!(token_from_header_or_field(&HeaderMap::new(), None), None);
879 }
880
881 #[test]
882 fn test_generate_token() {
883 let token1 = generate_token();
884 let token2 = generate_token();
885
886 // Tokens should be 64 hex characters (32 bytes)
887 assert_eq!(token1.len(), 64);
888 assert_eq!(token2.len(), 64);
889
890 // Tokens should be different
891 assert_ne!(token1, token2);
892 }
893
894 #[test]
895 fn test_constant_time_compare() {
896 use crate::helpers::constant_time_compare;
897 assert!(constant_time_compare("abc", "abc"));
898 assert!(!constant_time_compare("abc", "abd"));
899 assert!(!constant_time_compare("abc", "abcd"));
900 assert!(!constant_time_compare("", "a"));
901 }
902
903 #[test]
904 fn test_generate_token_is_hex() {
905 let token = generate_token();
906 // Should be valid hex
907 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
908 }
909
910 #[test]
911 fn test_extract_token_from_header() {
912 let mut headers = HeaderMap::new();
913 headers.insert("X-CSRF-Token", "abc123".parse().unwrap());
914 let token = extract_token_from_request(&headers, None);
915 assert_eq!(token.as_deref(), Some("abc123"));
916 }
917
918 #[test]
919 fn test_extract_token_from_form_body() {
920 let headers = HeaderMap::new();
921 let body = "name=value&_csrf=mytoken123&other=data";
922 let token = extract_token_from_request(&headers, Some(body));
923 assert_eq!(token.as_deref(), Some("mytoken123"));
924 }
925
926 #[test]
927 fn test_extract_token_missing() {
928 let headers = HeaderMap::new();
929 let token = extract_token_from_request(&headers, None);
930 assert!(token.is_none());
931 }
932
933 #[test]
934 fn test_generate_token_unique_across_many() {
935 let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect();
936 let unique: std::collections::HashSet<&String> = tokens.iter().collect();
937 assert_eq!(unique.len(), 100, "all 100 tokens should be unique");
938 }
939
940 #[test]
941 fn test_generate_token_correct_byte_length() {
942 let token = generate_token();
943 let bytes = hex::decode(&token).expect("token should be valid hex");
944 assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH);
945 }
946
947 #[test]
948 fn test_extract_token_header_takes_priority_over_body() {
949 let mut headers = HeaderMap::new();
950 headers.insert("X-CSRF-Token", "header_token".parse().unwrap());
951 let body = "_csrf=body_token";
952 let token = extract_token_from_request(&headers, Some(body));
953 assert_eq!(token.as_deref(), Some("header_token"));
954 }
955
956 #[test]
957 fn test_extract_token_from_body_url_encoded() {
958 let headers = HeaderMap::new();
959 let body = "_csrf=token%20with%20spaces&other=val";
960 let token = extract_token_from_request(&headers, Some(body));
961 assert_eq!(token.as_deref(), Some("token with spaces"));
962 }
963
964 #[test]
965 fn test_extract_token_csrf_at_start_of_body() {
966 let headers = HeaderMap::new();
967 let body = "_csrf=firstfield&name=value";
968 let token = extract_token_from_request(&headers, Some(body));
969 assert_eq!(token.as_deref(), Some("firstfield"));
970 }
971
972 #[test]
973 fn test_extract_token_csrf_at_end_of_body() {
974 let headers = HeaderMap::new();
975 let body = "name=value&_csrf=lastfield";
976 let token = extract_token_from_request(&headers, Some(body));
977 assert_eq!(token.as_deref(), Some("lastfield"));
978 }
979
980 #[test]
981 fn test_extract_token_empty_body() {
982 let headers = HeaderMap::new();
983 let token = extract_token_from_request(&headers, Some(""));
984 assert!(token.is_none());
985 }
986
987 #[test]
988 fn test_extract_token_body_without_csrf_field() {
989 let headers = HeaderMap::new();
990 let body = "name=value&other=data";
991 let token = extract_token_from_request(&headers, Some(body));
992 assert!(token.is_none());
993 }
994
995 #[test]
996 fn test_extract_token_csrf_prefix_mismatch() {
997 let headers = HeaderMap::new();
998 // Field named "_csrfx" should NOT match "_csrf="
999 let body = "_csrfx=notreal";
1000 let token = extract_token_from_request(&headers, Some(body));
1001 assert!(token.is_none());
1002 }
1003
1004 #[test]
1005 fn test_extract_token_empty_csrf_value() {
1006 let headers = HeaderMap::new();
1007 let body = "_csrf=&other=val";
1008 let token = extract_token_from_request(&headers, Some(body));
1009 assert_eq!(token.as_deref(), Some(""));
1010 }
1011
1012 #[test]
1013 fn test_constant_time_compare_empty_strings() {
1014 use crate::helpers::constant_time_compare;
1015 assert!(constant_time_compare("", ""));
1016 }
1017
1018 #[test]
1019 fn test_constant_time_compare_near_miss() {
1020 use crate::helpers::constant_time_compare;
1021 let token = generate_token();
1022 // Flip last character
1023 let mut tampered = token.clone();
1024 let last = tampered.pop().unwrap();
1025 tampered.push(if last == '0' { '1' } else { '0' });
1026 assert!(!constant_time_compare(&token, &tampered));
1027 }
1028
1029 #[test]
1030 fn csrf_manually_validated_marker_is_zero_sized() {
1031 assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0);
1032 }
1033
1034 #[test]
1035 fn csrf_posture_is_copyable_and_carries_reason() {
1036 let p = CsrfPosture::Skip("webhook: stripe signature");
1037 let copy = p;
1038 match copy {
1039 CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"),
1040 _ => panic!("variant mismatch"),
1041 }
1042 }
1043
1044 #[test]
1045 fn test_constant_time_compare_truncated() {
1046 use crate::helpers::constant_time_compare;
1047 let token = generate_token();
1048 let truncated = &token[..token.len() - 1];
1049 assert!(!constant_time_compare(&token, truncated));
1050 }
1051
1052 #[test]
1053 fn url_host_strips_scheme_port_and_path() {
1054 assert_eq!(
1055 url_host("https://makenot.work").as_deref(),
1056 Some("makenot.work")
1057 );
1058 assert_eq!(
1059 url_host("https://makenot.work:8443").as_deref(),
1060 Some("makenot.work")
1061 );
1062 assert_eq!(
1063 url_host("https://makenot.work/forgot-password?x=1").as_deref(),
1064 Some("makenot.work")
1065 );
1066 assert_eq!(
1067 url_host("http://EXAMPLE.com").as_deref(),
1068 Some("example.com")
1069 );
1070 assert_eq!(url_host("https://[::1]:8080/p").as_deref(), Some("[::1]"));
1071 }
1072
1073 #[test]
1074 fn url_host_rejects_opaque_and_schemeless() {
1075 assert_eq!(url_host("null"), None);
1076 assert_eq!(url_host("makenot.work"), None); // no scheme => unusable signal
1077 assert_eq!(url_host("https://"), None);
1078 }
1079
1080 fn headers(pairs: &[(&str, &str)]) -> axum::http::HeaderMap {
1081 let mut h = axum::http::HeaderMap::new();
1082 for (k, v) in pairs {
1083 h.insert(
1084 axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
1085 axum::http::HeaderValue::from_str(v).unwrap(),
1086 );
1087 }
1088 h
1089 }
1090
1091 #[test]
1092 fn cross_site_sec_fetch_site_is_authoritative() {
1093 assert!(is_cross_site(&headers(&[("sec-fetch-site", "cross-site")])));
1094 assert!(!is_cross_site(&headers(&[(
1095 "sec-fetch-site",
1096 "same-origin"
1097 )])));
1098 assert!(!is_cross_site(&headers(&[("sec-fetch-site", "same-site")])));
1099 assert!(!is_cross_site(&headers(&[("sec-fetch-site", "none")])));
1100 // case-insensitive
1101 assert!(is_cross_site(&headers(&[("sec-fetch-site", "Cross-Site")])));
1102 }
1103
1104 #[test]
1105 fn cross_site_origin_fallback_compares_host() {
1106 // Sec-Fetch-Site absent => fall back to Origin vs Host.
1107 assert!(is_cross_site(&headers(&[
1108 ("host", "makenot.work"),
1109 ("origin", "https://evil.example"),
1110 ])));
1111 assert!(!is_cross_site(&headers(&[
1112 ("host", "makenot.work"),
1113 ("origin", "https://makenot.work"),
1114 ])));
1115 // port differences don't matter (same host)
1116 assert!(!is_cross_site(&headers(&[
1117 ("host", "makenot.work:443"),
1118 ("origin", "https://makenot.work"),
1119 ])));
1120 // Referer used only when Origin is absent
1121 assert!(is_cross_site(&headers(&[
1122 ("host", "makenot.work"),
1123 ("referer", "https://evil.example/x"),
1124 ])));
1125 }
1126
1127 #[test]
1128 fn cross_site_no_signal_is_allowed() {
1129 // Header-less client (server-to-server, CLI): nothing to compare => allow.
1130 assert!(!is_cross_site(&headers(&[("host", "makenot.work")])));
1131 assert!(!is_cross_site(&headers(&[])));
1132 // Opaque/unparseable Origin yields no host => allow (positive-only policy).
1133 assert!(!is_cross_site(&headers(&[
1134 ("host", "makenot.work"),
1135 ("origin", "null"),
1136 ])));
1137 }
1138
1139 // ── CSRF carve-out manifest: every cookie-authable mutation is sealed ──
1140
1141 /// True if `verb(` occurs in `line` as a free-function call (axum's
1142 /// `post`/`put`/`patch`/`delete` method routers) rather than a method call
1143 /// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(`, the `(`
1144 /// must immediately follow the verb, which excludes `post_csrf`).
1145 fn has_raw_method_router(line: &str, verb: &str) -> bool {
1146 let needle = format!("{verb}(");
1147 let mut from = 0;
1148 while let Some(rel) = line[from..].find(&needle) {
1149 let at = from + rel;
1150 let prev = line[..at].chars().next_back();
1151 // Reject `.post(` (method call) and `xpost(` (identifier suffix like
1152 // `post_csrf` can't reach here, `(` follows the verb directly).
1153 if !matches!(prev, Some(c) if c == '.' || c.is_alphanumeric() || c == '_') {
1154 return true;
1155 }
1156 from = at + needle.len();
1157 }
1158 false
1159 }
1160
1161 /// Build-time seal (the D2 constructive fix): the router surfaces merged raw
1162 /// in `lib.rs` outside the finalized `CsrfRouter` must not register any
1163 /// mutating route that isn't a declared, justified [`CSRF_CARVE_OUTS`] entry.
1164 /// A future cookie-authed `post(...)` added to a carve-out surface, instead
1165 /// of `post_csrf(...)`, fails here rather than shipping a silent CSRF hole.
1166 #[test]
1167 fn every_raw_mutation_outside_csrf_router_is_justified() {
1168 use std::path::Path;
1169
1170 fn scan(path: &Path, contents: &str, offenders: &mut Vec<String>) {
1171 for (i, line) in contents.lines().enumerate() {
1172 let trimmed = line.trim_start();
1173 if trimmed.starts_with("//") || trimmed.starts_with('*') {
1174 continue;
1175 }
1176 let is_mutation = ["post", "put", "patch", "delete"]
1177 .iter()
1178 .any(|v| has_raw_method_router(line, v));
1179 if is_mutation
1180 && !CSRF_CARVE_OUTS
1181 .iter()
1182 .any(|(handler, _)| line.contains(handler))
1183 {
1184 offenders.push(format!("{}:{}: {}", path.display(), i + 1, trimmed));
1185 }
1186 }
1187 }
1188
1189 fn walk(dir: &Path, offenders: &mut Vec<String>) {
1190 let Ok(entries) = std::fs::read_dir(dir) else {
1191 return;
1192 };
1193 for entry in entries.flatten() {
1194 let path = entry.path();
1195 if path.is_dir() {
1196 walk(&path, offenders);
1197 } else if path.extension().is_some_and(|e| e == "rs")
1198 && let Ok(contents) = std::fs::read_to_string(&path)
1199 {
1200 scan(&path, &contents, offenders);
1201 }
1202 }
1203 }
1204
1205 let base = Path::new(env!("CARGO_MANIFEST_DIR"));
1206 // The surfaces merged raw in lib.rs, outside `.finalize()`.
1207 let surfaces = ["src/routes/pages", "src/routes/git", "src/routes/embed"];
1208 let mut offenders = Vec::new();
1209 for s in surfaces {
1210 walk(&base.join(s), &mut offenders);
1211 }
1212 // sso.rs is a single file, not a directory.
1213 let sso = base.join("src/routes/sso.rs");
1214 if let Ok(contents) = std::fs::read_to_string(&sso) {
1215 scan(&sso, &contents, &mut offenders);
1216 }
1217
1218 assert!(
1219 offenders.is_empty(),
1220 "Found cookie-authable mutation(s) merged OUTSIDE the CsrfRouter tree with no \
1221 justified CSRF_CARVE_OUTS entry. Route these through post_csrf/the CsrfRouter, \
1222 or (if genuinely non-cookie-authed) add a documented CSRF_CARVE_OUTS entry:\n{}",
1223 offenders.join("\n")
1224 );
1225 }
1226 }
1227