Skip to main content

max / makenotwork

15.0 KB · 352 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 // A described screen mounts only when `QUASI_SCREENS` names it, and the
89 // manifest is a process-global: a test elsewhere in this binary that
90 // switches one on leaves an entry behind for a path this harness does
91 // not serve, and every method then answers 405. Skipping them is not a
92 // hole. `a_described_write_rejects_an_authenticated_tokenless_mutation`
93 // below probes the same surface with the screen actually on, which is
94 // the only configuration where the question means anything.
95 if makenotwork::quasi::PATHS.contains(&entry.path.as_str()) {
96 continue;
97 }
98 // Authenticated session + no CSRF token + no form content-type:
99 // `validate_auto` must return 403 (Forbidden) before the handler runs.
100 // Path params in the manifest (e.g. `/api/items/{id}`) still match the
101 // route pattern, and validation rejects before any param is parsed.
102 let mut outcome: Option<(&str, u16)> = None;
103 for method in ["POST", "PUT", "PATCH", "DELETE"] {
104 next_ip(&mut h);
105 let resp = h
106 .client
107 .request_with_headers(method, &entry.path, None, &[])
108 .await;
109 let code = resp.status.as_u16();
110 if code == 403 {
111 outcome = Some((method, code));
112 break;
113 }
114 if code != 405 {
115 // The route handled this method but did NOT reject, the real
116 // (and only interesting) failure case.
117 outcome = Some((method, code));
118 break;
119 }
120 // 405: route doesn't handle this method; try the next one.
121 }
122 match outcome {
123 Some((_, 403)) => {}
124 Some((method, code)) => failures.push(format!("{} [{method}] -> {code}", entry.path)),
125 None => failures.push(format!(
126 "{} -> all methods 405 (no mutating method?)",
127 entry.path
128 )),
129 }
130 }
131
132 assert!(
133 failures.is_empty(),
134 "{} Auto route(s) did NOT reject a tokenless authenticated request (CSRF \
135 layer missing/bypassed). If a route is legitimately refused earlier by \
136 an outer layer, add it to REJECTED_BEFORE_CSRF_LAYER with a reason:\n{}",
137 failures.len(),
138 failures.join("\n")
139 );
140 }
141
142 /// Manifest sanity: it is populated, the posture mix is plausible, and every
143 /// opt-out (Manual/Skip) carries a documented justification at its call site.
144 #[tokio::test]
145 async fn csrf_manifest_is_populated_and_optouts_are_justified() {
146 let _h = TestHarness::new().await; // building the app populates the manifest
147 let manifest = route_manifest();
148
149 let count = |p: ManifestPosture| manifest.iter().filter(|e| e.posture == p).count();
150 let (auto, manual, skip) = (
151 count(ManifestPosture::Auto),
152 count(ManifestPosture::Manual),
153 count(ManifestPosture::Skip),
154 );
155 eprintln!(
156 "CSRF manifest: {auto} auto, {manual} manual, {skip} skip, {} total",
157 manifest.len()
158 );
159
160 assert!(
161 manifest.len() > 100,
162 "manifest too small: {}",
163 manifest.len()
164 );
165 assert!(auto > 50, "expected many Auto routes, got {auto}");
166
167 for e in &manifest {
168 if matches!(e.posture, ManifestPosture::Manual | ManifestPosture::Skip) {
169 assert!(
170 e.reason.is_some_and(|r| !r.trim().is_empty()),
171 "{:?} route {} has no documented CSRF justification",
172 e.posture,
173 e.path
174 );
175 }
176 }
177 }
178
179 /// CHRONIC A′ regression test (gap CLOSED 2026-06-15). `/forgot-password` is
180 /// Auto-posture and always reached logged-out. It used to slip through because
181 /// `validate_auto` skipped token validation for logged-out callers. That skip
182 /// is gone, so a pre-auth tokenless POST is now rejected by the per-route token
183 /// check. This pins the fix: if the `!has_user` skip is ever reintroduced, this
184 /// flips red.
185 #[tokio::test]
186 async fn forgot_password_rejects_preauth_tokenless_post() {
187 let mut h = TestHarness::new().await;
188 // Anonymous client (no signup) + no CSRF token + a real-looking form body.
189 // No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through,
190 // the rejection here comes from the per-route token check (the seal for
191 // header-less forged clients the origin gate intentionally lets pass).
192 let resp = h
193 .client
194 .request_with_headers(
195 "POST",
196 "/forgot-password",
197 Some("email=nobody@example.com"),
198 &[("Content-Type", "application/x-www-form-urlencoded")],
199 )
200 .await;
201
202 assert_eq!(
203 resp.status, 403,
204 "CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \
205 POST (got {}). The validate_auto !has_user skip must stay removed.",
206 resp.status
207 );
208 }
209
210 /// The posture-independent origin gate rejects a positively cross-site mutating
211 /// request regardless of posture or auth state, before the handler runs.
212 #[tokio::test]
213 async fn origin_gate_rejects_cross_site_mutation() {
214 let mut h = TestHarness::new().await;
215 let resp = h
216 .client
217 .request_with_headers(
218 "POST",
219 "/forgot-password",
220 Some("email=nobody@example.com"),
221 &[
222 ("Content-Type", "application/x-www-form-urlencoded"),
223 ("Sec-Fetch-Site", "cross-site"),
224 ],
225 )
226 .await;
227 assert_eq!(
228 resp.status, 403,
229 "origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})",
230 resp.status
231 );
232 }
233
234 /// A header-less request (no Sec-Fetch-Site, no Origin/Referer) is allowed past
235 /// the origin gate, this is the server-to-server / CLI path. It is still
236 /// subject to the per-route token check, so the response is whatever that check
237 /// returns (here 403 for a tokenless form), NOT a gate rejection. We assert the
238 /// gate did not block on a same-origin Sec-Fetch-Site signal.
239 #[tokio::test]
240 async fn origin_gate_allows_same_origin_signal() {
241 let mut h = TestHarness::new().await;
242 // same-origin Sec-Fetch-Site must pass the gate; with no token it then hits
243 // the token check. Use an authenticated session + a valid token would be a
244 // fuller test, but here we only assert the gate itself does not 403 on a
245 // same-origin signal by confirming the failure mode is the token layer, not
246 // an early gate block. A same-origin GET (safe method) is the cleanest probe.
247 let resp = h
248 .client
249 .request_with_headers(
250 "GET",
251 "/forgot-password",
252 None,
253 &[("Sec-Fetch-Site", "same-origin")],
254 )
255 .await;
256 assert_eq!(
257 resp.status, 200,
258 "origin gate or routing blocked a same-origin safe request (got {})",
259 resp.status
260 );
261 }
262
263 /// The description layer's mounts are inside the CSRF envelope, not beside it.
264 ///
265 /// They sat outside until 2026-08-11: `build_app` nested them after
266 /// `with_state`, so neither the origin gate nor the token check reached them.
267 /// That was harmless only while described screens served nothing but GET, and it
268 /// stopped being harmless the moment one served its own `DELETE`. This asserts
269 /// the structural fact rather than the behaviour, because the behaviour is only
270 /// observable when a screen is switched on and `QUASI_SCREENS` is unset here and
271 /// in every deployment.
272 #[tokio::test]
273 async fn described_screens_register_inside_the_csrf_envelope() {
274 // Building the app is what populates the manifest.
275 let _h = TestHarness::new().await;
276
277 let manifest = route_manifest();
278 // With no screen switched on there is nothing to find, which is the state
279 // this test runs in. What it pins is that `mounts` is wired through
280 // `CsrfRouter::nest_service`, so any screen that switches on is covered:
281 // an entry appears if and only if a mount did.
282 for entry in &manifest {
283 if entry.path.starts_with("/library/tabs/") || entry.path.starts_with("/dashboard/tabs/") {
284 assert_eq!(
285 entry.posture,
286 ManifestPosture::Auto,
287 "a described mount declared a posture other than Auto: {} ({:?})",
288 entry.path,
289 entry.reason
290 );
291 }
292 }
293
294 // The seal that actually matters, and the one a future refactor would trip:
295 // `CsrfRouter::nest_service` is the only way a service-shaped sub-tree gets
296 // in, and it records Auto unconditionally. If someone reaches for axum's
297 // `nest_service` on the finalized router again, the mount silently leaves
298 // the envelope and nothing above notices, so the source is checked too.
299 let lib = include_str!("../../src/lib.rs");
300 let mounts = lib
301 .split_once("quasi::mounts(state)")
302 .expect("build_app mounts the description layer")
303 .1;
304 let (mounted, _) = mounts
305 .split_once(".finalize()")
306 .expect("the CSRF tree is finalized after the mounts");
307 assert!(
308 mounted.contains("routes.nest_service"),
309 "the described mounts must go through CsrfRouter::nest_service and land \
310 before finalize, not on the plain router afterwards: see the CSRF \
311 envelope note in build_app"
312 );
313 }
314
315 /// A described write refuses a tokenless mutation, like every other write.
316 ///
317 /// The behavioural half of the test above. Switching a screen on is what makes
318 /// the described sub-tree reachable, and `library_contacts` is the one that
319 /// serves its own `DELETE`: it revokes contact sharing and answers with the tab
320 /// as it now stands, rather than calling the API route whose 204 htmx never
321 /// swapped.
322 #[tokio::test]
323 async fn a_described_write_rejects_an_authenticated_tokenless_mutation() {
324 let mut h = TestHarness::build(crate::harness::BuildOptions {
325 quasi_screens: makenotwork::config::QuasiScreens::parse("library_contacts"),
326 ..Default::default()
327 })
328 .await;
329 h.signup("creator", "creator@example.com", "password123")
330 .await;
331 h.login("creator", "password123").await;
332
333 // A real seller id is not needed: the CSRF layer wraps the whole nest, so it
334 // answers before the router is consulted about whether the path exists.
335 // That order is deliberate; see `CsrfRouter::nest_service`.
336 let resp = h
337 .client
338 .request_with_headers(
339 "DELETE",
340 "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
341 None,
342 &[],
343 )
344 .await;
345
346 assert_eq!(
347 resp.status, 403,
348 "a described write accepted a tokenless mutation (got {})",
349 resp.status
350 );
351 }
352