test: cover the trusted_proxy X-Forwarded-For rate-limit gate

Two integration tests pinning both sides of the gate: trusted_proxy=true gives
each forwarded IP its own bucket; trusted_proxy=false ignores spoofed XFF and
shares the global bucket. Closes audit gap M7. Test-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:13:37 +02:00
parent b5f7467c47
commit f2c9cfe162
2 changed files with 88 additions and 0 deletions

View File

@@ -9,6 +9,73 @@ fn creds(username: &str) -> serde_json::Value {
json!({ "username": username, "password": "hunter2hunter2" })
}
/// A login request carrying a chosen `X-Forwarded-For`. Empty password so the
/// handler consumes a rate-limit token then short-circuits with 400 before any
/// argon2/DB work — the burst drains near-instantly regardless of CI load.
fn login_from(xff: &str) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.header("x-forwarded-for", xff)
.body(axum::body::Body::from(
json!({ "username": "victim", "password": "" }).to_string(),
))
.unwrap()
}
// The per-IP rate limiter's soundness hinges on one gate: X-Forwarded-For is
// honored ONLY when AUTH_TRUSTED_PROXY is set. These two tests pin both sides of
// that gate at the request level (the pure parser is unit-tested separately).
#[sqlx::test(migrations = "./migrations")]
async fn trusted_proxy_gives_each_forwarded_ip_its_own_bucket(pool: PgPool) {
let h = common::harness_with_auth_rate_limit_proxy(pool, 1, 2, true);
// Drain IP A's bucket (per_sec=1, burst=2) until it 429s.
let mut a_saw_429 = false;
for _ in 0..8 {
let resp = h.app.clone().oneshot(login_from("203.0.113.10")).await.unwrap();
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
a_saw_429 = true;
break;
}
}
assert!(a_saw_429, "IP A must be rate-limited after draining its own bucket");
// A different forwarded IP has an independent bucket — its first hit is not 429.
let resp_b = h.app.clone().oneshot(login_from("198.51.100.20")).await.unwrap();
assert_ne!(
resp_b.status(),
StatusCode::TOO_MANY_REQUESTS,
"a distinct X-Forwarded-For hop must get its own bucket, not A's drained one"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn untrusted_proxy_ignores_forwarded_ip_and_shares_one_bucket(pool: PgPool) {
let h = common::harness_with_auth_rate_limit_proxy(pool, 1, 2, false);
// Every request carries a DISTINCT (spoofed) X-Forwarded-For, but the backend
// doesn't trust it — so they all fall back to the single global bucket, which
// drains and starts returning 429. If XFF were wrongly honored here, each
// unique IP would get a fresh bucket and none of these would ever 429.
let mut saw_429 = false;
for i in 0..12 {
let resp = h
.app
.clone()
.oneshot(login_from(&format!("10.0.0.{i}")))
.await
.unwrap();
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
saw_429 = true;
break;
}
}
assert!(
saw_429,
"with trusted_proxy off, spoofed X-Forwarded-For must NOT dodge the shared limiter"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn register_creates_user_and_sets_session_cookie(pool: PgPool) {
let h = common::harness(pool);