|
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 |
+ |
/// Auto-posture paths with a known, tracked pre-auth CSRF gap (CHRONIC A′):
|
|
30 |
+ |
/// `validate_auto` skips token validation when there is no session user, so a
|
|
31 |
+ |
/// route reached logged-out is unprotected against a cross-site forged POST.
|
|
32 |
+ |
/// The structural fix (a global, posture-independent Sec-Fetch-Site / Origin
|
|
33 |
+ |
/// gate) is tracked in `docs/launchplan_final.md`. Removing an entry here
|
|
34 |
+ |
/// without landing that fix will make `forgot_password_preauth_csrf_gap_is_tracked`
|
|
35 |
+ |
/// fail — that is the intended forcing function.
|
|
36 |
+ |
const KNOWN_PREAUTH_CSRF_GAPS: &[&str] = &["/forgot-password"];
|
|
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′ tracker. `/forgot-password` is Auto-posture but always reached
|
|
157 |
+ |
/// logged-out, and `validate_auto` skips token validation when there is no
|
|
158 |
+ |
/// session user — so its declared "session token IS its protection" never runs
|
|
159 |
+ |
/// for the real caller, and a pre-auth forged POST reaches the handler.
|
|
160 |
+ |
///
|
|
161 |
+ |
/// This PINS the current (vulnerable) behavior so the regression test exists and
|
|
162 |
+ |
/// flips loudly when the gap closes. When the global origin gate lands and this
|
|
163 |
+ |
/// route starts rejecting pre-auth forged POSTs, this assertion fails — at which
|
|
164 |
+ |
/// point remove `/forgot-password` from KNOWN_PREAUTH_CSRF_GAPS and invert the
|
|
165 |
+ |
/// assertion to `assert_eq!(403)`.
|
|
166 |
+ |
#[tokio::test]
|
|
167 |
+ |
async fn forgot_password_preauth_csrf_gap_is_tracked() {
|
|
168 |
+ |
assert!(
|
|
169 |
+ |
KNOWN_PREAUTH_CSRF_GAPS.contains(&"/forgot-password"),
|
|
170 |
+ |
"test out of sync with KNOWN_PREAUTH_CSRF_GAPS"
|
|
171 |
+ |
);
|
|
172 |
+ |
|
|
173 |
+ |
let mut h = TestHarness::new().await;
|
|
174 |
+ |
// Anonymous client (no signup) + no CSRF token + a real-looking form body.
|
|
175 |
+ |
let resp = h
|
|
176 |
+ |
.client
|
|
177 |
+ |
.request_with_headers(
|
|
178 |
+ |
"POST",
|
|
179 |
+ |
"/forgot-password",
|
|
180 |
+ |
Some("email=nobody@example.com"),
|
|
181 |
+ |
&[("Content-Type", "application/x-www-form-urlencoded")],
|
|
182 |
+ |
)
|
|
183 |
+ |
.await;
|
|
184 |
+ |
|
|
185 |
+ |
// Gap open today: the handler runs (success/redirect), CSRF did not reject.
|
|
186 |
+ |
assert!(
|
|
187 |
+ |
resp.status.is_success() || resp.status.is_redirection(),
|
|
188 |
+ |
"Expected the pre-auth handler to run (gap open), got {}",
|
|
189 |
+ |
resp.status
|
|
190 |
+ |
);
|
|
191 |
+ |
assert_ne!(
|
|
192 |
+ |
resp.status,
|
|
193 |
+ |
403,
|
|
194 |
+ |
"CHRONIC A' appears FIXED: /forgot-password now rejects a pre-auth \
|
|
195 |
+ |
tokenless POST. Remove it from KNOWN_PREAUTH_CSRF_GAPS and invert this \
|
|
196 |
+ |
assertion to assert_eq!(resp.status, 403)."
|
|
197 |
+ |
);
|
|
198 |
+ |
}
|