//! 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::{ManifestPosture, route_manifest}; /// 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() { // Log in as ADMIN so requests clear the access gate AND the `/admin*` admin // gate (Run 20), reaching the per-route CSRF layer on every auto route, // including admin mutations. A non-admin would be rejected by the admin gate // (404) before the CSRF layer on `/admin*` routes, masking their CSRF // coverage here. The client tracks the session cookie; `request_with_headers` // deliberately does NOT inject a CSRF token, so each request is // authenticated-but-tokenless. let (mut h, _admin_id) = TestHarness::with_admin().await; h.login("admin", "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; } // A described screen mounts only when `QUASI_SCREENS` names it, and the // manifest is a process-global: a test elsewhere in this binary that // switches one on leaves an entry behind for a path this harness does // not serve, and every method then answers 405. Skipping them is not a // hole. `a_described_write_rejects_an_authenticated_tokenless_mutation` // below probes the same surface with the screen actually on, which is // the only configuration where the question means anything. if makenotwork::quasi::PATHS.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.is_some_and(|r| !r.trim().is_empty()), "{:?} 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 same-origin `Sec-Fetch-Site` signal must pass the origin gate. The probe is /// a safe GET to `/forgot-password`: a 200 shows the gate did not block on the /// same-origin 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_eq!( resp.status, 200, "origin gate or routing blocked a same-origin safe request (got {})", resp.status ); } /// The description layer's mounts are inside the CSRF envelope, not beside it. /// /// They sat outside until 2026-08-11: `build_app` nested them after /// `with_state`, so neither the origin gate nor the token check reached them. /// That was harmless only while described screens served nothing but GET, and it /// stopped being harmless the moment one served its own `DELETE`. This asserts /// the structural fact rather than the behaviour, because the behaviour is only /// observable when a screen is switched on and `QUASI_SCREENS` is unset here and /// in every deployment. #[tokio::test] async fn described_screens_register_inside_the_csrf_envelope() { // Building the app is what populates the manifest. let _h = TestHarness::new().await; let manifest = route_manifest(); // With no screen switched on there is nothing to find, which is the state // this test runs in. What it pins is that `mounts` is wired through // `CsrfRouter::nest_service`, so any screen that switches on is covered: // an entry appears if and only if a mount did. for entry in &manifest { if entry.path.starts_with("/library/tabs/") || entry.path.starts_with("/dashboard/tabs/") { assert_eq!( entry.posture, ManifestPosture::Auto, "a described mount declared a posture other than Auto: {} ({:?})", entry.path, entry.reason ); } } // The seal that actually matters, and the one a future refactor would trip: // `CsrfRouter::nest_service` is the only way a service-shaped sub-tree gets // in, and it records Auto unconditionally. If someone reaches for axum's // `nest_service` on the finalized router again, the mount silently leaves // the envelope and nothing above notices, so the source is checked too. let lib = include_str!("../../src/lib.rs"); let mounts = lib .split_once("quasi::mounts(state)") .expect("build_app mounts the description layer") .1; let (mounted, _) = mounts .split_once(".finalize()") .expect("the CSRF tree is finalized after the mounts"); assert!( mounted.contains("routes.nest_service"), "the described mounts must go through CsrfRouter::nest_service and land \ before finalize, not on the plain router afterwards: see the CSRF \ envelope note in build_app" ); } /// A described write refuses a tokenless mutation, like every other write. /// /// The behavioural half of the test above. Switching a screen on is what makes /// the described sub-tree reachable, and `library_contacts` is the one that /// serves its own `DELETE`: it revokes contact sharing and answers with the tab /// as it now stands, rather than calling the API route whose 204 htmx never /// swapped. #[tokio::test] async fn a_described_write_rejects_an_authenticated_tokenless_mutation() { let mut h = TestHarness::build(crate::harness::BuildOptions { ..Default::default() }) .await; h.signup("creator", "creator@example.com", "password123") .await; h.login("creator", "password123").await; // A real seller id is not needed: the CSRF layer wraps the whole nest, so it // answers before the router is consulted about whether the path exists. // That order is deliberate; see `CsrfRouter::nest_service`. let resp = h .client .request_with_headers( "DELETE", "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000", None, &[], ) .await; assert_eq!( resp.status, 403, "a described write accepted a tokenless mutation (got {})", resp.status ); } /// A described write SUCCEEDS with the token the document around it carries. /// /// The other half, and the one that was missing. The test above asserts the /// enforcement works; nothing asserted a described write can be completed at /// all, so a surface where every write answered 403 would have read as covered. /// /// # Where the token comes from, measured 2026-08-18 /// /// Every described screen answers a `Response::fragment`, so it is the inside of /// an element in an Askama document, and that document is what carries the /// token: `base.html` emits `` and the core module /// attaches it to every htmx request on the page. So this walks the browser's /// path exactly -- read the page, take the meta, use it on the write -- with the /// test doing by hand what `htmx-glue.ts` does on `htmx:config:request`. /// /// The document-owning case is the other half and no screen exercises it yet: /// see `a_described_screen_never_builds_its_own_shell` below and /// `Viewer::shell`. #[tokio::test] async fn a_described_write_accepts_the_token_the_page_around_it_carries() { let mut h = TestHarness::build(crate::harness::BuildOptions { ..Default::default() }) .await; h.signup("creator", "creator@example.com", "password123") .await; h.login("creator", "password123").await; // The document the tab lives in, not the tab: the tab is a fragment and a // fragment carries no head. let page = h.client.get("/library").await; assert_eq!(page.status, 200, "the library page did not render"); let token = meta_csrf_token(&page.text) .expect("the page around a described screen carries no csrf-token meta"); let resp = h .client .request_with_headers( "DELETE", "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000", None, &[("X-CSRF-Token", token.as_str()), ("HX-Request", "true")], ) .await; // What the write answers about a contact that does not exist is the // handler's business. This asserts only that it reached the handler. assert_ne!( resp.status, 403, "a described write refused the token its own document supplied" ); } /// The token out of ``, the way the glue reads it. fn meta_csrf_token(html: &str) -> Option { let at = html.split("name=\"csrf-token\"").nth(1)?; let at = at.split("content=\"").nth(1)?; Some(at.split('"').next()?.to_owned()) } /// No described screen builds its own shell. /// /// The failure this forbids is silent and one line long: a screen that writes /// `Shell::under("/static")` by hand gets a document with no session token in /// it, and every write on that screen answers 403 the moment the screen owns /// its document rather than answering into an Askama page. Five screens wrote /// that line before `Viewer::shell` existed, which was five chances to forget. /// /// Source-level rather than behavioural because there is nothing to observe /// yet: every described screen answers a fragment today, and a fragment has no /// `` to carry anything. The test that would catch it is the one that /// cannot be written until the mistake ships. #[test] fn a_described_screen_never_builds_its_own_shell() { for (name, source) in [ ("ssh_keys", include_str!("../../src/quasi/ssh_keys.rs")), ( "library_contacts", include_str!("../../src/quasi/library_contacts.rs"), ), ( "buyer_contacts", include_str!("../../src/quasi/buyer_contacts.rs"), ), ( "user_analytics", include_str!("../../src/quasi/user_analytics.rs"), ), ( "forum_memberships", include_str!("../../src/quasi/forum_memberships.rs"), ), ] { assert!( !source.contains("Shell::under"), "{name} builds its own shell: use `viewer.shell()`, which carries \ the session token every write on a described document needs" ); } }