//! Router-coverage CSRF test. //! //! Enumerates every mutating route registered through `CsrfRouter` (via the //! manifest harvested at registration time, `makenotwork::csrf::route_manifest`) //! and asserts the whole-router CSRF invariant in one place — instead of relying //! on per-route tests, or on an audit, to notice a route that drifted. //! //! Why this exists: CSRF has surfaced in nearly every audit run because the //! protection is enforced opt-in across ~350 per-route posture declarations plus //! a multi-branch middleware. Each run an adversarial reader finds a different //! site that drifted (route registration, the auto-posture pre-auth branch, a //! mis-declared skip). This test converts "did every route get protected?" from //! a code-reading exercise into a CI assertion that fails the moment a route //! drifts. use crate::harness::TestHarness; use makenotwork::csrf::{route_manifest, ManifestPosture}; /// Auto-posture paths where an *outer* layer (e.g. the access gate) rejects the /// test user with a non-403 status before the per-route CSRF layer runs, so the /// strict `== 403` assertion below does not apply. Each entry must name the /// layer that rejects it. The request is still refused — just not by the CSRF /// layer — so excluding it does not weaken the security claim. Keep this list /// short and justified; an unexplained entry is a smell. const REJECTED_BEFORE_CSRF_LAYER: &[&str] = &[ // (populated empirically — see test output if this list is wrong) ]; // CHRONIC A′ (the pre-auth CSRF gap) is CLOSED as of 2026-06-15: `validate_auto` // no longer skips token validation for logged-out callers, and the // posture-independent `origin_gate` (layered on the whole `CsrfRouter` tree in // `finalize`) rejects positively cross-site mutations. There is no longer a // tracked-gap allowlist; `forgot_password_rejects_preauth_tokenless_post` below // pins the fix. If a new pre-auth gap is ever knowingly accepted, reintroduce a // justified allowlist + a test that flips when it closes (see git history for // the prior `KNOWN_PREAUTH_CSRF_GAPS` forcing-function pattern). /// Core coverage assertion: every Auto-posture route rejects an authenticated /// request that carries no CSRF token. This catches a route that lost its auto /// validation layer (the #18-class regression) across the whole router at once. #[tokio::test] async fn every_auto_route_rejects_authenticated_tokenless_mutation() { let mut h = TestHarness::new().await; // Log in a user so requests clear the access gate and reach the per-route // CSRF layer. The client tracks the session cookie; `request_with_headers` // deliberately does NOT inject a CSRF token, so each request is // authenticated-but-tokenless. h.signup("csrfcov", "csrfcov@example.com", "password123").await; let manifest = route_manifest(); let auto_routes: Vec<_> = manifest .iter() .filter(|e| e.posture == ManifestPosture::Auto) .collect(); assert!( auto_routes.len() > 50, "manifest looks empty/broken: {} auto of {} total routes", auto_routes.len(), manifest.len() ); // Each manifest path may register any mutating method (single-method // helpers like `put_csrf`, or multi-method `with_csrf(get().post())`); the // manifest keys by path, not method. So probe the four mutating methods and // require that the one the route actually handles is rejected with 403. A // method the route does not handle returns 405 (the auto layer does not wrap // the method-not-allowed fallback) — that is not a CSRF result, so we move // on. The client IP is rotated every request so the per-IP rate limiter // (an outer layer that would 429 before the CSRF layer) never trips. let mut ip_counter: u32 = 0; let mut next_ip = |h: &mut TestHarness| { ip_counter += 1; h.client .set_forwarded_ip(&format!("10.50.{}.{}", (ip_counter / 256) % 256, ip_counter % 256)); }; let mut failures = Vec::new(); for entry in &auto_routes { if REJECTED_BEFORE_CSRF_LAYER.contains(&entry.path.as_str()) { continue; } // Authenticated session + no CSRF token + no form content-type: // `validate_auto` must return 403 (Forbidden) before the handler runs. // Path params in the manifest (e.g. `/api/items/{id}`) still match the // route pattern, and validation rejects before any param is parsed. let mut outcome: Option<(&str, u16)> = None; for method in ["POST", "PUT", "PATCH", "DELETE"] { next_ip(&mut h); let resp = h .client .request_with_headers(method, &entry.path, None, &[]) .await; let code = resp.status.as_u16(); if code == 403 { outcome = Some((method, code)); break; } if code != 405 { // The route handled this method but did NOT reject — the real // (and only interesting) failure case. outcome = Some((method, code)); break; } // 405: route doesn't handle this method; try the next one. } match outcome { Some((_, 403)) => {} Some((method, code)) => failures.push(format!("{} [{method}] -> {code}", entry.path)), None => failures.push(format!("{} -> all methods 405 (no mutating method?)", entry.path)), } } assert!( failures.is_empty(), "{} Auto route(s) did NOT reject a tokenless authenticated request (CSRF \ layer missing/bypassed). If a route is legitimately refused earlier by \ an outer layer, add it to REJECTED_BEFORE_CSRF_LAYER with a reason:\n{}", failures.len(), failures.join("\n") ); } /// Manifest sanity: it is populated, the posture mix is plausible, and every /// opt-out (Manual/Skip) carries a documented justification at its call site. #[tokio::test] async fn csrf_manifest_is_populated_and_optouts_are_justified() { let _h = TestHarness::new().await; // building the app populates the manifest let manifest = route_manifest(); let count = |p: ManifestPosture| manifest.iter().filter(|e| e.posture == p).count(); let (auto, manual, skip) = ( count(ManifestPosture::Auto), count(ManifestPosture::Manual), count(ManifestPosture::Skip), ); eprintln!( "CSRF manifest: {auto} auto, {manual} manual, {skip} skip, {} total", manifest.len() ); assert!(manifest.len() > 100, "manifest too small: {}", manifest.len()); assert!(auto > 50, "expected many Auto routes, got {auto}"); for e in &manifest { if matches!(e.posture, ManifestPosture::Manual | ManifestPosture::Skip) { assert!( e.reason.map(|r| !r.trim().is_empty()).unwrap_or(false), "{:?} route {} has no documented CSRF justification", e.posture, e.path ); } } } /// CHRONIC A′ regression test (gap CLOSED 2026-06-15). `/forgot-password` is /// Auto-posture and always reached logged-out. It used to slip through because /// `validate_auto` skipped token validation for logged-out callers. That skip /// is gone, so a pre-auth tokenless POST is now rejected by the per-route token /// check. This pins the fix: if the `!has_user` skip is ever reintroduced, this /// flips red. #[tokio::test] async fn forgot_password_rejects_preauth_tokenless_post() { let mut h = TestHarness::new().await; // Anonymous client (no signup) + no CSRF token + a real-looking form body. // No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through — // the rejection here comes from the per-route token check (the seal for // header-less forged clients the origin gate intentionally lets pass). let resp = h .client .request_with_headers( "POST", "/forgot-password", Some("email=nobody@example.com"), &[("Content-Type", "application/x-www-form-urlencoded")], ) .await; assert_eq!( resp.status, 403, "CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \ POST (got {}). The validate_auto !has_user skip must stay removed.", resp.status ); } /// The posture-independent origin gate rejects a positively cross-site mutating /// request regardless of posture or auth state, before the handler runs. #[tokio::test] async fn origin_gate_rejects_cross_site_mutation() { let mut h = TestHarness::new().await; let resp = h .client .request_with_headers( "POST", "/forgot-password", Some("email=nobody@example.com"), &[ ("Content-Type", "application/x-www-form-urlencoded"), ("Sec-Fetch-Site", "cross-site"), ], ) .await; assert_eq!( resp.status, 403, "origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})", resp.status ); } /// A header-less request (no Sec-Fetch-Site, no Origin/Referer) is allowed past /// the origin gate — this is the server-to-server / CLI path. It is still /// subject to the per-route token check, so the response is whatever that check /// returns (here 403 for a tokenless form), NOT a gate rejection. We assert the /// gate did not block on a same-origin Sec-Fetch-Site signal. #[tokio::test] async fn origin_gate_allows_same_origin_signal() { let mut h = TestHarness::new().await; // same-origin Sec-Fetch-Site must pass the gate; with no token it then hits // the token check. Use an authenticated session + a valid token would be a // fuller test, but here we only assert the gate itself does not 403 on a // same-origin signal by confirming the failure mode is the token layer, not // an early gate block. A same-origin GET (safe method) is the cleanest probe. let resp = h .client .request_with_headers( "GET", "/forgot-password", None, &[("Sec-Fetch-Site", "same-origin")], ) .await; assert!( resp.status.is_success(), "origin gate or routing blocked a same-origin safe request (got {})", resp.status ); }