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" }) 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")] #[sqlx::test(migrations = "./migrations")]
async fn register_creates_user_and_sets_session_cookie(pool: PgPool) { async fn register_creates_user_and_sets_session_cookie(pool: PgPool) {
let h = common::harness(pool); let h = common::harness(pool);

View File

@@ -148,6 +148,27 @@ pub fn harness_with_auth_rate_limit(
harness_with_auth_config(pool, storage, storage_dir, auth) harness_with_auth_config(pool, storage, storage_dir, auth)
} }
/// Like [`harness_with_auth_rate_limit`] but also sets whether the backend
/// trusts `X-Forwarded-For` (`AUTH_TRUSTED_PROXY`). Used to prove the per-IP
/// rate-limit gate: with `trusted_proxy` on, distinct XFF hops get independent
/// buckets; with it off, XFF is ignored and everything shares the global bucket.
pub fn harness_with_auth_rate_limit_proxy(
pool: PgPool,
per_sec: u32,
burst: u32,
trusted_proxy: bool,
) -> Harness {
let storage_dir = tempfile::tempdir().expect("tempdir");
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
let auth = AuthConfig {
cookie_secure: false,
trusted_proxy,
rate_limit: mangalord::auth::rate_limit::RateLimitConfig { per_sec, burst },
..AuthConfig::default()
};
harness_with_auth_config(pool, storage, storage_dir, auth)
}
/// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub /// Like [`harness`] but slots a caller-supplied [`ResyncService`] stub
/// into `AppState.resync`. Used by the admin resync tests so the /// into `AppState.resync`. Used by the admin resync tests so the
/// endpoint path is exercised without standing up a real Chromium. /// endpoint path is exercised without standing up a real Chromium.