Skip to main content

max / makenotwork

10.5 KB · 252 lines History Blame Raw
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::{ManifestPosture, route_manifest};
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 // CHRONIC A′ (the pre-auth CSRF gap) is CLOSED as of 2026-06-15: `validate_auto`
30 // no longer skips token validation for logged-out callers, and the
31 // posture-independent `origin_gate` (layered on the whole `CsrfRouter` tree in
32 // `finalize`) rejects positively cross-site mutations. There is no longer a
33 // tracked-gap allowlist; `forgot_password_rejects_preauth_tokenless_post` below
34 // pins the fix. If a new pre-auth gap is ever knowingly accepted, reintroduce a
35 // justified allowlist + a test that flips when it closes (see git history for
36 // the prior `KNOWN_PREAUTH_CSRF_GAPS` forcing-function pattern).
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 // Log in as ADMIN so requests clear the access gate AND the `/admin*` admin
44 // gate (Run 20), reaching the per-route CSRF layer on every auto route,
45 // including admin mutations. A non-admin would be rejected by the admin gate
46 // (404) before the CSRF layer on `/admin*` routes, masking their CSRF
47 // coverage here. The client tracks the session cookie; `request_with_headers`
48 // deliberately does NOT inject a CSRF token, so each request is
49 // authenticated-but-tokenless.
50 let (mut h, _admin_id) = TestHarness::with_admin().await;
51 h.login("admin", "password123").await;
52
53 let manifest = route_manifest();
54 let auto_routes: Vec<_> = manifest
55 .iter()
56 .filter(|e| e.posture == ManifestPosture::Auto)
57 .collect();
58 assert!(
59 auto_routes.len() > 50,
60 "manifest looks empty/broken: {} auto of {} total routes",
61 auto_routes.len(),
62 manifest.len()
63 );
64
65 // Each manifest path may register any mutating method (single-method
66 // helpers like `put_csrf`, or multi-method `with_csrf(get().post())`); the
67 // manifest keys by path, not method. So probe the four mutating methods and
68 // require that the one the route actually handles is rejected with 403. A
69 // method the route does not handle returns 405 (the auto layer does not wrap
70 // the method-not-allowed fallback), that is not a CSRF result, so we move
71 // on. The client IP is rotated every request so the per-IP rate limiter
72 // (an outer layer that would 429 before the CSRF layer) never trips.
73 let mut ip_counter: u32 = 0;
74 let mut next_ip = |h: &mut TestHarness| {
75 ip_counter += 1;
76 h.client.set_forwarded_ip(&format!(
77 "10.50.{}.{}",
78 (ip_counter / 256) % 256,
79 ip_counter % 256
80 ));
81 };
82
83 let mut failures = Vec::new();
84 for entry in &auto_routes {
85 if REJECTED_BEFORE_CSRF_LAYER.contains(&entry.path.as_str()) {
86 continue;
87 }
88 // Authenticated session + no CSRF token + no form content-type:
89 // `validate_auto` must return 403 (Forbidden) before the handler runs.
90 // Path params in the manifest (e.g. `/api/items/{id}`) still match the
91 // route pattern, and validation rejects before any param is parsed.
92 let mut outcome: Option<(&str, u16)> = None;
93 for method in ["POST", "PUT", "PATCH", "DELETE"] {
94 next_ip(&mut h);
95 let resp = h
96 .client
97 .request_with_headers(method, &entry.path, None, &[])
98 .await;
99 let code = resp.status.as_u16();
100 if code == 403 {
101 outcome = Some((method, code));
102 break;
103 }
104 if code != 405 {
105 // The route handled this method but did NOT reject, the real
106 // (and only interesting) failure case.
107 outcome = Some((method, code));
108 break;
109 }
110 // 405: route doesn't handle this method; try the next one.
111 }
112 match outcome {
113 Some((_, 403)) => {}
114 Some((method, code)) => failures.push(format!("{} [{method}] -> {code}", entry.path)),
115 None => failures.push(format!(
116 "{} -> all methods 405 (no mutating method?)",
117 entry.path
118 )),
119 }
120 }
121
122 assert!(
123 failures.is_empty(),
124 "{} Auto route(s) did NOT reject a tokenless authenticated request (CSRF \
125 layer missing/bypassed). If a route is legitimately refused earlier by \
126 an outer layer, add it to REJECTED_BEFORE_CSRF_LAYER with a reason:\n{}",
127 failures.len(),
128 failures.join("\n")
129 );
130 }
131
132 /// Manifest sanity: it is populated, the posture mix is plausible, and every
133 /// opt-out (Manual/Skip) carries a documented justification at its call site.
134 #[tokio::test]
135 async fn csrf_manifest_is_populated_and_optouts_are_justified() {
136 let _h = TestHarness::new().await; // building the app populates the manifest
137 let manifest = route_manifest();
138
139 let count = |p: ManifestPosture| manifest.iter().filter(|e| e.posture == p).count();
140 let (auto, manual, skip) = (
141 count(ManifestPosture::Auto),
142 count(ManifestPosture::Manual),
143 count(ManifestPosture::Skip),
144 );
145 eprintln!(
146 "CSRF manifest: {auto} auto, {manual} manual, {skip} skip, {} total",
147 manifest.len()
148 );
149
150 assert!(
151 manifest.len() > 100,
152 "manifest too small: {}",
153 manifest.len()
154 );
155 assert!(auto > 50, "expected many Auto routes, got {auto}");
156
157 for e in &manifest {
158 if matches!(e.posture, ManifestPosture::Manual | ManifestPosture::Skip) {
159 assert!(
160 e.reason.is_some_and(|r| !r.trim().is_empty()),
161 "{:?} route {} has no documented CSRF justification",
162 e.posture,
163 e.path
164 );
165 }
166 }
167 }
168
169 /// CHRONIC A′ regression test (gap CLOSED 2026-06-15). `/forgot-password` is
170 /// Auto-posture and always reached logged-out. It used to slip through because
171 /// `validate_auto` skipped token validation for logged-out callers. That skip
172 /// is gone, so a pre-auth tokenless POST is now rejected by the per-route token
173 /// check. This pins the fix: if the `!has_user` skip is ever reintroduced, this
174 /// flips red.
175 #[tokio::test]
176 async fn forgot_password_rejects_preauth_tokenless_post() {
177 let mut h = TestHarness::new().await;
178 // Anonymous client (no signup) + no CSRF token + a real-looking form body.
179 // No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through,
180 // the rejection here comes from the per-route token check (the seal for
181 // header-less forged clients the origin gate intentionally lets pass).
182 let resp = h
183 .client
184 .request_with_headers(
185 "POST",
186 "/forgot-password",
187 Some("email=nobody@example.com"),
188 &[("Content-Type", "application/x-www-form-urlencoded")],
189 )
190 .await;
191
192 assert_eq!(
193 resp.status, 403,
194 "CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \
195 POST (got {}). The validate_auto !has_user skip must stay removed.",
196 resp.status
197 );
198 }
199
200 /// The posture-independent origin gate rejects a positively cross-site mutating
201 /// request regardless of posture or auth state, before the handler runs.
202 #[tokio::test]
203 async fn origin_gate_rejects_cross_site_mutation() {
204 let mut h = TestHarness::new().await;
205 let resp = h
206 .client
207 .request_with_headers(
208 "POST",
209 "/forgot-password",
210 Some("email=nobody@example.com"),
211 &[
212 ("Content-Type", "application/x-www-form-urlencoded"),
213 ("Sec-Fetch-Site", "cross-site"),
214 ],
215 )
216 .await;
217 assert_eq!(
218 resp.status, 403,
219 "origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})",
220 resp.status
221 );
222 }
223
224 /// A header-less request (no Sec-Fetch-Site, no Origin/Referer) is allowed past
225 /// the origin gate, this is the server-to-server / CLI path. It is still
226 /// subject to the per-route token check, so the response is whatever that check
227 /// returns (here 403 for a tokenless form), NOT a gate rejection. We assert the
228 /// gate did not block on a same-origin Sec-Fetch-Site signal.
229 #[tokio::test]
230 async fn origin_gate_allows_same_origin_signal() {
231 let mut h = TestHarness::new().await;
232 // same-origin Sec-Fetch-Site must pass the gate; with no token it then hits
233 // the token check. Use an authenticated session + a valid token would be a
234 // fuller test, but here we only assert the gate itself does not 403 on a
235 // same-origin signal by confirming the failure mode is the token layer, not
236 // an early gate block. A same-origin GET (safe method) is the cleanest probe.
237 let resp = h
238 .client
239 .request_with_headers(
240 "GET",
241 "/forgot-password",
242 None,
243 &[("Sec-Fetch-Site", "same-origin")],
244 )
245 .await;
246 assert_eq!(
247 resp.status, 200,
248 "origin gate or routing blocked a same-origin safe request (got {})",
249 resp.status
250 );
251 }
252