Skip to main content

max / makenotwork

Add whole-router CSRF coverage manifest + tracker tests Harvest every route's declared CSRF posture into a process-global manifest as a side effect of CsrfRouter registration (the one site that sees every mutating path). PostureMethodRouter now carries its posture so route() can record it. Adds tests/workflows/csrf_coverage.rs: - every_auto_route_rejects_authenticated_tokenless_mutation: whole-router assertion catching the #18-class regression (a route losing its auto layer) in one place instead of per-route tests or an audit. - csrf_manifest_is_populated_and_optouts_are_justified: manifest sanity + every Manual/Skip opt-out carries a documented reason. - forgot_password_preauth_csrf_gap_is_tracked: pins CHRONIC A' (the validate_auto !has_user pre-auth skip) so it flips red when the global origin gate lands. All three green (gap currently pinned open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 20:22 UTC
Commit: 02b0274daacb91bcdedb540af80b888f55da7d1f
Parent: 873dc06
3 files changed, +283 insertions, -16 deletions
M server/src/csrf.rs +84 -16
@@ -15,10 +15,66 @@ use axum::{
15 15 Router,
16 16 };
17 17 use rand::RngCore;
18 + use std::collections::BTreeMap;
19 + use std::sync::{LazyLock, Mutex};
18 20 use tower_sessions::Session;
19 21
20 22 use crate::error::{AppError, ResultExt};
21 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 { path: path.to_string(), posture, reason },
68 + );
69 + }
70 +
71 + /// Snapshot of every mutating route registered through [`CsrfRouter`], with its
72 + /// declared CSRF posture. Populated as a side effect of route registration, so
73 + /// it is only complete after `build_app` has run at least once in the process.
74 + pub fn route_manifest() -> Vec<CsrfRouteEntry> {
75 + CSRF_MANIFEST.lock().unwrap().values().cloned().collect()
76 + }
77 +
22 78 /// Session key for storing CSRF token
23 79 pub const CSRF_SESSION_KEY: &str = "csrf_token";
24 80
@@ -135,9 +191,10 @@ where
135 191 /// Per-route CSRF posture, declared at the route registration site via the
136 192 /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures
137 193 /// so the choice (and its reason) lives next to the route, not in a sibling
138 - /// allowlist file. Not stored at runtime — the reason strings exist for
139 - /// source-level documentation and grep, while the structural guarantee comes
140 - /// from `CsrfRouter` only accepting `PostureMethodRouter` values.
194 + /// allowlist file. The structural guarantee comes from `CsrfRouter` only
195 + /// accepting `PostureMethodRouter` values; the posture is also carried on the
196 + /// `PostureMethodRouter` so `CsrfRouter::route` can harvest it into the
197 + /// [`route_manifest`] consumed by the router-coverage test.
141 198 #[derive(Clone, Copy, Debug)]
142 199 pub enum CsrfPosture {
143 200 /// Standard validation layer runs (header or form `_csrf`).
@@ -214,18 +271,25 @@ pub use posture_router::PostureMethodRouter;
214 271 mod posture_router {
215 272 use super::*;
216 273
217 - pub struct PostureMethodRouter<S = ()>(pub(super) MethodRouter<S>);
274 + pub struct PostureMethodRouter<S = ()> {
275 + inner: MethodRouter<S>,
276 + posture: CsrfPosture,
277 + }
218 278
219 279 impl<S> PostureMethodRouter<S>
220 280 where
221 281 S: Clone + Send + Sync + 'static,
222 282 {
223 - pub(super) fn new(inner: MethodRouter<S>) -> Self {
224 - Self(inner)
283 + pub(super) fn new(inner: MethodRouter<S>, posture: CsrfPosture) -> Self {
284 + Self { inner, posture }
225 285 }
226 286
227 287 pub(super) fn into_inner(self) -> MethodRouter<S> {
228 - self.0
288 + self.inner
289 + }
290 +
291 + pub(super) fn posture(&self) -> CsrfPosture {
292 + self.posture
229 293 }
230 294
231 295 /// Attach an additional tower layer (e.g. a rate limiter) to the
@@ -243,7 +307,7 @@ mod posture_router {
243 307 <L::Service as tower::Service<axum::extract::Request>>::Future:
244 308 Send + 'static,
245 309 {
246 - Self(self.0.layer(layer))
310 + Self { inner: self.inner.layer(layer), posture: self.posture }
247 311 }
248 312 }
249 313 }
@@ -256,7 +320,10 @@ macro_rules! csrf_auto_helper {
256 320 T: 'static,
257 321 S: Clone + Send + Sync + 'static,
258 322 {
259 - posture_router::PostureMethodRouter::new(attach_auto_layer($axum_fn(handler)))
323 + posture_router::PostureMethodRouter::new(
324 + attach_auto_layer($axum_fn(handler)),
325 + CsrfPosture::Auto,
326 + )
260 327 }
261 328 };
262 329 }
@@ -269,8 +336,10 @@ macro_rules! csrf_passthrough_helper {
269 336 T: 'static,
270 337 S: Clone + Send + Sync + 'static,
271 338 {
272 - let _ = CsrfPosture::$variant(reason);
273 - posture_router::PostureMethodRouter::new($axum_fn(handler))
339 + posture_router::PostureMethodRouter::new(
340 + $axum_fn(handler),
341 + CsrfPosture::$variant(reason),
342 + )
274 343 }
275 344 };
276 345 }
@@ -308,7 +377,7 @@ pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S>
308 377 where
309 378 S: Clone + Send + Sync + 'static,
310 379 {
311 - posture_router::PostureMethodRouter::new(attach_auto_layer(method_router))
380 + posture_router::PostureMethodRouter::new(attach_auto_layer(method_router), CsrfPosture::Auto)
312 381 }
313 382
314 383 /// Stamp a multi-method chain as Manual — handler is responsible for
@@ -320,8 +389,7 @@ pub fn with_csrf_manual<S>(
320 389 where
321 390 S: Clone + Send + Sync + 'static,
322 391 {
323 - let _ = CsrfPosture::Manual(reason);
324 - posture_router::PostureMethodRouter::new(method_router)
392 + posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Manual(reason))
325 393 }
326 394
327 395 /// Stamp a multi-method chain as Skip — no CSRF check applies.
@@ -332,8 +400,7 @@ pub fn with_csrf_skip<S>(
332 400 where
333 401 S: Clone + Send + Sync + 'static,
334 402 {
335 - let _ = CsrfPosture::Skip(reason);
336 - posture_router::PostureMethodRouter::new(method_router)
403 + posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Skip(reason))
337 404 }
338 405
339 406 // --- CsrfRouter: structural enforcement ----------------------------------
@@ -365,6 +432,7 @@ where
365 432 }
366 433
367 434 pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
435 + record_route(path, posture.posture());
368 436 Self(self.0.route(path, posture.into_inner()))
369 437 }
370 438
@@ -0,0 +1,198 @@
1 + //! Router-coverage CSRF test.
2 + //!
3 + //! Enumerates every mutating route registered through `CsrfRouter` (via the
4 + //! manifest harvested at registration time, `makenotwork::csrf::route_manifest`)
5 + //! and asserts the whole-router CSRF invariant in one place — instead of relying
6 + //! on per-route tests, or on an audit, to notice a route that drifted.
7 + //!
8 + //! Why this exists: CSRF has surfaced in nearly every audit run because the
9 + //! protection is enforced opt-in across ~350 per-route posture declarations plus
10 + //! a multi-branch middleware. Each run an adversarial reader finds a different
11 + //! site that drifted (route registration, the auto-posture pre-auth branch, a
12 + //! mis-declared skip). This test converts "did every route get protected?" from
13 + //! a code-reading exercise into a CI assertion that fails the moment a route
14 + //! drifts.
15 +
16 + use crate::harness::TestHarness;
17 + use makenotwork::csrf::{route_manifest, ManifestPosture};
18 +
19 + /// Auto-posture paths where an *outer* layer (e.g. the access gate) rejects the
20 + /// test user with a non-403 status before the per-route CSRF layer runs, so the
21 + /// strict `== 403` assertion below does not apply. Each entry must name the
22 + /// layer that rejects it. The request is still refused — just not by the CSRF
23 + /// layer — so excluding it does not weaken the security claim. Keep this list
24 + /// short and justified; an unexplained entry is a smell.
25 + const REJECTED_BEFORE_CSRF_LAYER: &[&str] = &[
26 + // (populated empirically — see test output if this list is wrong)
27 + ];
28 +
29 + /// Auto-posture paths with a known, tracked pre-auth CSRF gap (CHRONIC A′):
30 + /// `validate_auto` skips token validation when there is no session user, so a
31 + /// route reached logged-out is unprotected against a cross-site forged POST.
32 + /// The structural fix (a global, posture-independent Sec-Fetch-Site / Origin
33 + /// gate) is tracked in `docs/launchplan_final.md`. Removing an entry here
34 + /// without landing that fix will make `forgot_password_preauth_csrf_gap_is_tracked`
35 + /// fail — that is the intended forcing function.
36 + const KNOWN_PREAUTH_CSRF_GAPS: &[&str] = &["/forgot-password"];
37 +
38 + /// Core coverage assertion: every Auto-posture route rejects an authenticated
39 + /// request that carries no CSRF token. This catches a route that lost its auto
40 + /// validation layer (the #18-class regression) across the whole router at once.
41 + #[tokio::test]
42 + async fn every_auto_route_rejects_authenticated_tokenless_mutation() {
43 + let mut h = TestHarness::new().await;
44 + // Log in a user so requests clear the access gate and reach the per-route
45 + // CSRF layer. The client tracks the session cookie; `request_with_headers`
46 + // deliberately does NOT inject a CSRF token, so each request is
47 + // authenticated-but-tokenless.
48 + h.signup("csrfcov", "csrfcov@example.com", "password123").await;
49 +
50 + let manifest = route_manifest();
51 + let auto_routes: Vec<_> = manifest
52 + .iter()
53 + .filter(|e| e.posture == ManifestPosture::Auto)
54 + .collect();
55 + assert!(
56 + auto_routes.len() > 50,
57 + "manifest looks empty/broken: {} auto of {} total routes",
58 + auto_routes.len(),
59 + manifest.len()
60 + );
61 +
62 + // Each manifest path may register any mutating method (single-method
63 + // helpers like `put_csrf`, or multi-method `with_csrf(get().post())`); the
64 + // manifest keys by path, not method. So probe the four mutating methods and
65 + // require that the one the route actually handles is rejected with 403. A
66 + // method the route does not handle returns 405 (the auto layer does not wrap
67 + // the method-not-allowed fallback) — that is not a CSRF result, so we move
68 + // on. The client IP is rotated every request so the per-IP rate limiter
69 + // (an outer layer that would 429 before the CSRF layer) never trips.
70 + let mut ip_counter: u32 = 0;
71 + let mut next_ip = |h: &mut TestHarness| {
72 + ip_counter += 1;
73 + h.client
74 + .set_forwarded_ip(&format!("10.50.{}.{}", (ip_counter / 256) % 256, ip_counter % 256));
75 + };
76 +
77 + let mut failures = Vec::new();
78 + for entry in &auto_routes {
79 + if REJECTED_BEFORE_CSRF_LAYER.contains(&entry.path.as_str()) {
80 + continue;
81 + }
82 + // Authenticated session + no CSRF token + no form content-type:
83 + // `validate_auto` must return 403 (Forbidden) before the handler runs.
84 + // Path params in the manifest (e.g. `/api/items/{id}`) still match the
85 + // route pattern, and validation rejects before any param is parsed.
86 + let mut outcome: Option<(&str, u16)> = None;
87 + for method in ["POST", "PUT", "PATCH", "DELETE"] {
88 + next_ip(&mut h);
89 + let resp = h
90 + .client
91 + .request_with_headers(method, &entry.path, None, &[])
92 + .await;
93 + let code = resp.status.as_u16();
94 + if code == 403 {
95 + outcome = Some((method, code));
96 + break;
97 + }
98 + if code != 405 {
99 + // The route handled this method but did NOT reject — the real
100 + // (and only interesting) failure case.
101 + outcome = Some((method, code));
102 + break;
103 + }
104 + // 405: route doesn't handle this method; try the next one.
105 + }
106 + match outcome {
107 + Some((_, 403)) => {}
108 + Some((method, code)) => failures.push(format!("{} [{method}] -> {code}", entry.path)),
109 + None => failures.push(format!("{} -> all methods 405 (no mutating method?)", entry.path)),
110 + }
111 + }
112 +
113 + assert!(
114 + failures.is_empty(),
115 + "{} Auto route(s) did NOT reject a tokenless authenticated request (CSRF \
116 + layer missing/bypassed). If a route is legitimately refused earlier by \
117 + an outer layer, add it to REJECTED_BEFORE_CSRF_LAYER with a reason:\n{}",
118 + failures.len(),
119 + failures.join("\n")
120 + );
121 + }
122 +
123 + /// Manifest sanity: it is populated, the posture mix is plausible, and every
124 + /// opt-out (Manual/Skip) carries a documented justification at its call site.
125 + #[tokio::test]
126 + async fn csrf_manifest_is_populated_and_optouts_are_justified() {
127 + let _h = TestHarness::new().await; // building the app populates the manifest
128 + let manifest = route_manifest();
129 +
130 + let count = |p: ManifestPosture| manifest.iter().filter(|e| e.posture == p).count();
131 + let (auto, manual, skip) = (
132 + count(ManifestPosture::Auto),
133 + count(ManifestPosture::Manual),
134 + count(ManifestPosture::Skip),
135 + );
136 + eprintln!(
137 + "CSRF manifest: {auto} auto, {manual} manual, {skip} skip, {} total",
138 + manifest.len()
139 + );
140 +
141 + assert!(manifest.len() > 100, "manifest too small: {}", manifest.len());
142 + assert!(auto > 50, "expected many Auto routes, got {auto}");
143 +
144 + for e in &manifest {
145 + if matches!(e.posture, ManifestPosture::Manual | ManifestPosture::Skip) {
146 + assert!(
147 + e.reason.map(|r| !r.trim().is_empty()).unwrap_or(false),
148 + "{:?} route {} has no documented CSRF justification",
149 + e.posture,
150 + e.path
151 + );
152 + }
153 + }
154 + }
155 +
156 + /// CHRONIC A′ tracker. `/forgot-password` is Auto-posture but always reached
157 + /// logged-out, and `validate_auto` skips token validation when there is no
158 + /// session user — so its declared "session token IS its protection" never runs
159 + /// for the real caller, and a pre-auth forged POST reaches the handler.
160 + ///
161 + /// This PINS the current (vulnerable) behavior so the regression test exists and
162 + /// flips loudly when the gap closes. When the global origin gate lands and this
163 + /// route starts rejecting pre-auth forged POSTs, this assertion fails — at which
164 + /// point remove `/forgot-password` from KNOWN_PREAUTH_CSRF_GAPS and invert the
165 + /// assertion to `assert_eq!(403)`.
166 + #[tokio::test]
167 + async fn forgot_password_preauth_csrf_gap_is_tracked() {
168 + assert!(
169 + KNOWN_PREAUTH_CSRF_GAPS.contains(&"/forgot-password"),
170 + "test out of sync with KNOWN_PREAUTH_CSRF_GAPS"
171 + );
172 +
173 + let mut h = TestHarness::new().await;
174 + // Anonymous client (no signup) + no CSRF token + a real-looking form body.
175 + let resp = h
176 + .client
177 + .request_with_headers(
178 + "POST",
179 + "/forgot-password",
180 + Some("email=nobody@example.com"),
181 + &[("Content-Type", "application/x-www-form-urlencoded")],
182 + )
183 + .await;
184 +
185 + // Gap open today: the handler runs (success/redirect), CSRF did not reject.
186 + assert!(
187 + resp.status.is_success() || resp.status.is_redirection(),
188 + "Expected the pre-auth handler to run (gap open), got {}",
189 + resp.status
190 + );
191 + assert_ne!(
192 + resp.status,
193 + 403,
194 + "CHRONIC A' appears FIXED: /forgot-password now rejects a pre-auth \
195 + tokenless POST. Remove it from KNOWN_PREAUTH_CSRF_GAPS and invert this \
196 + assertion to assert_eq!(resp.status, 403)."
197 + );
198 + }
@@ -1,5 +1,6 @@
1 1 mod access_gate;
2 2 mod auth;
3 + mod csrf_coverage;
3 4 mod custom_pages;
4 5 mod sso;
5 6 mod discover;