Skip to main content

max / makenotwork

18.5 KB · 447 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 /// `/forgot-password` is Auto-posture and always reached logged-out. A pre-auth
180 /// tokenless POST must be rejected by the per-route token check. This flips red
181 /// if `validate_auto` ever reintroduces a `!has_user` skip of token validation.
182 #[tokio::test]
183 async fn forgot_password_rejects_preauth_tokenless_post() {
184 let mut h = TestHarness::new().await;
185 // Anonymous client (no signup) + no CSRF token + a real-looking form body.
186 // No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through,
187 // the rejection here comes from the per-route token check (the seal for
188 // header-less forged clients the origin gate intentionally lets pass).
189 let resp = h
190 .client
191 .request_with_headers(
192 "POST",
193 "/forgot-password",
194 Some("email=nobody@example.com"),
195 &[("Content-Type", "application/x-www-form-urlencoded")],
196 )
197 .await;
198
199 assert_eq!(
200 resp.status, 403,
201 "CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \
202 POST (got {}). The validate_auto !has_user skip must stay removed.",
203 resp.status
204 );
205 }
206
207 /// The posture-independent origin gate rejects a positively cross-site mutating
208 /// request regardless of posture or auth state, before the handler runs.
209 #[tokio::test]
210 async fn origin_gate_rejects_cross_site_mutation() {
211 let mut h = TestHarness::new().await;
212 let resp = h
213 .client
214 .request_with_headers(
215 "POST",
216 "/forgot-password",
217 Some("email=nobody@example.com"),
218 &[
219 ("Content-Type", "application/x-www-form-urlencoded"),
220 ("Sec-Fetch-Site", "cross-site"),
221 ],
222 )
223 .await;
224 assert_eq!(
225 resp.status, 403,
226 "origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})",
227 resp.status
228 );
229 }
230
231 /// A same-origin `Sec-Fetch-Site` signal must pass the origin gate. The probe is
232 /// a safe GET to `/forgot-password`: a 200 shows the gate did not block on the
233 /// same-origin signal.
234 #[tokio::test]
235 async fn origin_gate_allows_same_origin_signal() {
236 let mut h = TestHarness::new().await;
237 // same-origin Sec-Fetch-Site must pass the gate; with no token it then hits
238 // the token check. Use an authenticated session + a valid token would be a
239 // fuller test, but here we only assert the gate itself does not 403 on a
240 // same-origin signal by confirming the failure mode is the token layer, not
241 // an early gate block. A same-origin GET (safe method) is the cleanest probe.
242 let resp = h
243 .client
244 .request_with_headers(
245 "GET",
246 "/forgot-password",
247 None,
248 &[("Sec-Fetch-Site", "same-origin")],
249 )
250 .await;
251 assert_eq!(
252 resp.status, 200,
253 "origin gate or routing blocked a same-origin safe request (got {})",
254 resp.status
255 );
256 }
257
258 /// The description layer's mounts are inside the CSRF envelope, not beside it.
259 ///
260 /// Nesting them after `with_state` puts them outside, where neither the origin
261 /// gate nor the token check reaches them. That is harmless only while described
262 /// screens serve nothing but GET, and a screen serving its own `DELETE` ends
263 /// that. This asserts
264 /// the structural fact rather than the behaviour, because the behaviour is only
265 /// observable when a screen is switched on and `QUASI_SCREENS` is unset here and
266 /// in every deployment.
267 #[tokio::test]
268 async fn described_screens_register_inside_the_csrf_envelope() {
269 // Building the app is what populates the manifest.
270 let _h = TestHarness::new().await;
271
272 let manifest = route_manifest();
273 // With no screen switched on there is nothing to find, which is the state
274 // this test runs in. What it pins is that `mounts` is wired through
275 // `CsrfRouter::nest_service`, so any screen that switches on is covered:
276 // an entry appears if and only if a mount did.
277 for entry in &manifest {
278 if entry.path.starts_with("/library/tabs/") || entry.path.starts_with("/dashboard/tabs/") {
279 assert_eq!(
280 entry.posture,
281 ManifestPosture::Auto,
282 "a described mount declared a posture other than Auto: {} ({:?})",
283 entry.path,
284 entry.reason
285 );
286 }
287 }
288
289 // The seal that actually matters, and the one a future refactor would trip:
290 // `CsrfRouter::nest_service` is the only way a service-shaped sub-tree gets
291 // in, and it records Auto unconditionally. If someone reaches for axum's
292 // `nest_service` on the finalized router again, the mount silently leaves
293 // the envelope and nothing above notices, so the source is checked too.
294 let lib = include_str!("../../src/lib.rs");
295 let mounts = lib
296 .split_once("quasi::mounts(state)")
297 .expect("build_app mounts the description layer")
298 .1;
299 let (mounted, _) = mounts
300 .split_once(".finalize()")
301 .expect("the CSRF tree is finalized after the mounts");
302 assert!(
303 mounted.contains("routes.nest_service"),
304 "the described mounts must go through CsrfRouter::nest_service and land \
305 before finalize, not on the plain router afterwards: see the CSRF \
306 envelope note in build_app"
307 );
308 }
309
310 /// A described write refuses a tokenless mutation, like every other write.
311 ///
312 /// The behavioural half of the test above. Switching a screen on is what makes
313 /// the described sub-tree reachable, and `library_contacts` is the one that
314 /// serves its own `DELETE`: it revokes contact sharing and answers with the tab
315 /// as it now stands, rather than calling the API route whose 204 htmx never
316 /// swapped.
317 #[tokio::test]
318 async fn a_described_write_rejects_an_authenticated_tokenless_mutation() {
319 let mut h = TestHarness::build(crate::harness::BuildOptions {
320 ..Default::default()
321 })
322 .await;
323 h.signup("creator", "creator@example.com", "password123")
324 .await;
325 h.login("creator", "password123").await;
326
327 // A real seller id is not needed: the CSRF layer wraps the whole nest, so it
328 // answers before the router is consulted about whether the path exists.
329 // That order is deliberate; see `CsrfRouter::nest_service`.
330 let resp = h
331 .client
332 .request_with_headers(
333 "DELETE",
334 "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
335 None,
336 &[],
337 )
338 .await;
339
340 assert_eq!(
341 resp.status, 403,
342 "a described write accepted a tokenless mutation (got {})",
343 resp.status
344 );
345 }
346
347 /// A described write SUCCEEDS with the token the document around it carries.
348 ///
349 /// The other half, and the one that was missing. The test above asserts the
350 /// enforcement works; nothing asserted a described write can be completed at
351 /// all, so a surface where every write answered 403 would have read as covered.
352 ///
353 /// # Where the token comes from
354 ///
355 /// Every described screen answers a `Response::fragment`, so it is the inside of
356 /// an element in an Askama document, and that document is what carries the
357 /// token: `base.html` emits `<meta name="csrf-token">` and the core module
358 /// attaches it to every htmx request on the page. So this walks the browser's
359 /// path exactly -- read the page, take the meta, use it on the write -- with the
360 /// test doing by hand what `htmx-glue.ts` does on `htmx:config:request`.
361 ///
362 /// The document-owning case is the other half and no screen exercises it yet:
363 /// see `a_described_screen_never_builds_its_own_shell` below and
364 /// `Viewer::shell`.
365 #[tokio::test]
366 async fn a_described_write_accepts_the_token_the_page_around_it_carries() {
367 let mut h = TestHarness::build(crate::harness::BuildOptions {
368 ..Default::default()
369 })
370 .await;
371 h.signup("creator", "creator@example.com", "password123")
372 .await;
373 h.login("creator", "password123").await;
374
375 // The document the tab lives in, not the tab: the tab is a fragment and a
376 // fragment carries no head.
377 let page = h.client.get("/library").await;
378 assert_eq!(page.status, 200, "the library page did not render");
379 let token = meta_csrf_token(&page.text)
380 .expect("the page around a described screen carries no csrf-token meta");
381
382 let resp = h
383 .client
384 .request_with_headers(
385 "DELETE",
386 "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
387 None,
388 &[("X-CSRF-Token", token.as_str()), ("HX-Request", "true")],
389 )
390 .await;
391
392 // What the write answers about a contact that does not exist is the
393 // handler's business. This asserts only that it reached the handler.
394 assert_ne!(
395 resp.status, 403,
396 "a described write refused the token its own document supplied"
397 );
398 }
399
400 /// The token out of `<meta name="csrf-token">`, the way the glue reads it.
401 fn meta_csrf_token(html: &str) -> Option<String> {
402 let at = html.split("name=\"csrf-token\"").nth(1)?;
403 let at = at.split("content=\"").nth(1)?;
404 Some(at.split('"').next()?.to_owned())
405 }
406
407 /// No described screen builds its own shell.
408 ///
409 /// The failure this forbids is silent and one line long: a screen that writes
410 /// `Shell::under("/static")` by hand gets a document with no session token in
411 /// it, and every write on that screen answers 403 the moment the screen owns
412 /// its document rather than answering into an Askama page. Five screens wrote
413 /// that line before `Viewer::shell` existed, which was five chances to forget.
414 ///
415 /// Source-level rather than behavioural because there is nothing to observe
416 /// yet: every described screen answers a fragment today, and a fragment has no
417 /// `<body>` to carry anything. The test that would catch it is the one that
418 /// cannot be written until the mistake ships.
419 #[test]
420 fn a_described_screen_never_builds_its_own_shell() {
421 for (name, source) in [
422 ("ssh_keys", include_str!("../../src/quasi/ssh_keys.rs")),
423 (
424 "library_contacts",
425 include_str!("../../src/quasi/library_contacts.rs"),
426 ),
427 (
428 "buyer_contacts",
429 include_str!("../../src/quasi/buyer_contacts.rs"),
430 ),
431 (
432 "user_analytics",
433 include_str!("../../src/quasi/user_analytics.rs"),
434 ),
435 (
436 "forum_memberships",
437 include_str!("../../src/quasi/forum_memberships.rs"),
438 ),
439 ] {
440 assert!(
441 !source.contains("Shell::under"),
442 "{name} builds its own shell: use `viewer.shell()`, which carries \
443 the session token every write on a described document needs"
444 );
445 }
446 }
447