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:
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user