Skip to main content

max / makenotwork

10.2 KB · 239 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::{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 // 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 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′ regression test (gap CLOSED 2026-06-15). `/forgot-password` is
157 /// Auto-posture and always reached logged-out. It used to slip through because
158 /// `validate_auto` skipped token validation for logged-out callers. That skip
159 /// is gone, so a pre-auth tokenless POST is now rejected by the per-route token
160 /// check. This pins the fix: if the `!has_user` skip is ever reintroduced, this
161 /// flips red.
162 #[tokio::test]
163 async fn forgot_password_rejects_preauth_tokenless_post() {
164 let mut h = TestHarness::new().await;
165 // Anonymous client (no signup) + no CSRF token + a real-looking form body.
166 // No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through —
167 // the rejection here comes from the per-route token check (the seal for
168 // header-less forged clients the origin gate intentionally lets pass).
169 let resp = h
170 .client
171 .request_with_headers(
172 "POST",
173 "/forgot-password",
174 Some("email=nobody@example.com"),
175 &[("Content-Type", "application/x-www-form-urlencoded")],
176 )
177 .await;
178
179 assert_eq!(
180 resp.status, 403,
181 "CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \
182 POST (got {}). The validate_auto !has_user skip must stay removed.",
183 resp.status
184 );
185 }
186
187 /// The posture-independent origin gate rejects a positively cross-site mutating
188 /// request regardless of posture or auth state, before the handler runs.
189 #[tokio::test]
190 async fn origin_gate_rejects_cross_site_mutation() {
191 let mut h = TestHarness::new().await;
192 let resp = h
193 .client
194 .request_with_headers(
195 "POST",
196 "/forgot-password",
197 Some("email=nobody@example.com"),
198 &[
199 ("Content-Type", "application/x-www-form-urlencoded"),
200 ("Sec-Fetch-Site", "cross-site"),
201 ],
202 )
203 .await;
204 assert_eq!(
205 resp.status, 403,
206 "origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})",
207 resp.status
208 );
209 }
210
211 /// A header-less request (no Sec-Fetch-Site, no Origin/Referer) is allowed past
212 /// the origin gate — this is the server-to-server / CLI path. It is still
213 /// subject to the per-route token check, so the response is whatever that check
214 /// returns (here 403 for a tokenless form), NOT a gate rejection. We assert the
215 /// gate did not block on a same-origin Sec-Fetch-Site signal.
216 #[tokio::test]
217 async fn origin_gate_allows_same_origin_signal() {
218 let mut h = TestHarness::new().await;
219 // same-origin Sec-Fetch-Site must pass the gate; with no token it then hits
220 // the token check. Use an authenticated session + a valid token would be a
221 // fuller test, but here we only assert the gate itself does not 403 on a
222 // same-origin signal by confirming the failure mode is the token layer, not
223 // an early gate block. A same-origin GET (safe method) is the cleanest probe.
224 let resp = h
225 .client
226 .request_with_headers(
227 "GET",
228 "/forgot-password",
229 None,
230 &[("Sec-Fetch-Site", "same-origin")],
231 )
232 .await;
233 assert!(
234 resp.status.is_success(),
235 "origin gate or routing blocked a same-origin safe request (got {})",
236 resp.status
237 );
238 }
239