From f2c9cfe1624979e1f27fb3321cd315b4c0604390 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 21:13:37 +0200 Subject: [PATCH] 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 --- backend/tests/api_auth.rs | 67 +++++++++++++++++++++++++++++++++++++ backend/tests/common/mod.rs | 21 ++++++++++++ 2 files changed, 88 insertions(+) diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index c54cbb9..d389e12 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -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::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); diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4a0253b..b9a6945 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -148,6 +148,27 @@ pub fn harness_with_auth_rate_limit( 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 /// into `AppState.resync`. Used by the admin resync tests so the /// endpoint path is exercised without standing up a real Chromium.