Lines
100 %
Functions
Branches
use super::*;
const INGRESS: &str = "10.104.0.0/16";
fn peer(addr: &str) -> Option<SocketAddr> {
Some(SocketAddr::new(
addr.parse().expect("test address parses"),
45678,
))
}
fn limiter() -> AuthLimiter {
AuthLimiter::new(TrustedProxies::from_spec(INGRESS))
#[test]
fn attempts_are_allowed_up_to_the_limit_then_refused() {
let limiter = limiter();
let client = peer("203.0.113.7");
for attempt in 1..=MAX_ATTEMPTS {
assert_eq!(
limiter.check(client, None),
Decision::Allow,
"attempt {attempt} is within the window's budget"
);
assert_eq!(limiter.check(client, None), Decision::Deny);
fn one_address_running_out_does_not_affect_another() {
let noisy = peer("203.0.113.7");
for _ in 0..=MAX_ATTEMPTS {
limiter.check(noisy, None);
assert_eq!(limiter.check(noisy, None), Decision::Deny);
limiter.check(peer("198.51.100.4"), None),
"throttling must be per client, not global"
fn a_forged_forwarded_header_cannot_buy_a_fresh_budget() {
let attacker = peer("203.0.113.7");
for _ in 0..MAX_ATTEMPTS {
limiter.check(attacker, None);
assert_eq!(limiter.check(attacker, None), Decision::Deny);
// The attacker connects directly, so its header is not evidence and must
// not move it to a different bucket.
for forged in ["1.2.3.4", "5.6.7.8", "9.10.11.12"] {
limiter.check(attacker, Some(forged)),
Decision::Deny,
"an untrusted peer must not escape its bucket by claiming {forged}"
fn clients_behind_a_trusted_proxy_get_their_own_budgets() {
let ingress = peer("10.104.0.9");
limiter.check(ingress, Some("203.0.113.7"));
assert_eq!(limiter.check(ingress, Some("203.0.113.7")), Decision::Deny);
limiter.check(ingress, Some("198.51.100.4")),
"one throttled client must not throttle everyone sharing the ingress"
fn rotating_hosts_inside_one_ipv6_allocation_share_a_budget() {
// 2^64 addresses out of one /64 would otherwise be 2^64 fresh budgets.
for host in 0..MAX_ATTEMPTS {
limiter.check(peer(&format!("2001:db8:1:1::{host:x}")), None),
Decision::Allow
limiter.check(peer("2001:db8:1:1::ffff"), None),
"host rotation within a /64 must not mint new budgets"
fn a_separate_ipv6_network_keeps_its_own_budget() {
for host in 0..=MAX_ATTEMPTS {
limiter.check(peer(&format!("2001:db8:1:1::{host:x}")), None);
assert_eq!(limiter.check(peer("2001:db8:1:1::1"), None), Decision::Deny);
limiter.check(peer("2001:db8:1:2::1"), None),
"a different /64 is a different client"
fn a_request_with_no_peer_is_counted_rather_than_waved_through() {
assert_eq!(limiter.check(None, None), Decision::Allow);
limiter.check(None, None),
"an unidentifiable request must still be throttled"
fn the_global_ceiling_binds_even_when_every_attempt_comes_from_a_new_client() {
// Each attempt uses a fresh /64, so no per-client bucket ever fills.
let mut allowed = 0;
for network in 0..(GLOBAL_MAX_ATTEMPTS + 50) {
if limiter
.check(peer(&format!("2001:db8:{network:x}::1")), None)
.is_denied()
{
break;
allowed += 1;
allowed, GLOBAL_MAX_ATTEMPTS,
"identity rotation must still spend from a budget it cannot reset"
fn verifications_have_their_own_far_larger_budget() {
// Exhausting the login budget must not stop this client from making
// ordinary authenticated requests.
limiter.check(client, None);
limiter.check_verification(client, None),
"a throttled login must not also lock the client out of its session"
fn a_flood_of_forged_tokens_is_refused() {
for _ in 0..MAX_VERIFICATIONS {
assert_eq!(limiter.check_verification(attacker, None), Decision::Allow);
limiter.check_verification(attacker, None),
"unbounded verification lets attacker-chosen subjects drain the admin pool"
fn verification_budgets_are_per_client() {
for _ in 0..=MAX_VERIFICATIONS {
limiter.check_verification(peer("203.0.113.7"), None);
limiter.check_verification(peer("203.0.113.7"), None),
Decision::Deny
limiter.check_verification(peer("198.51.100.4"), None),
fn a_forged_forwarded_header_cannot_buy_a_fresh_verification_budget() {
limiter.check_verification(attacker, None);
limiter.check_verification(attacker, Some("1.2.3.4")),
"an untrusted peer must not escape its bucket by claiming another address"
fn a_refused_client_does_not_spend_the_shared_budget() {
// Far more requests than the global ceiling, all from one address that is
// past its own limit after the first ten.
for _ in 0..(GLOBAL_MAX_ATTEMPTS * 2) {
"one address hammering a bucket it has already exhausted must not deny \
login to everybody else"
/// Drives the middleware itself, not just the counter beneath it.
mod middleware {
use super::super::{AuthLimiter, throttle_auth, throttle_verification};
use crate::client_ip::TrustedProxies;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use axum::{Router, body::Body, middleware};
use std::net::SocketAddr;
use std::sync::Arc;
use tower::ServiceExt;
fn app(handler: fn(Arc<AuthLimiter>) -> Router) -> Router {
handler(Arc::new(AuthLimiter::new(TrustedProxies::from_spec(
"10.104.0.0/20",
))))
fn login_app(limiter: Arc<AuthLimiter>) -> Router {
Router::new()
.route("/auth/login", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(limiter, throttle_auth))
fn page_app(limiter: Arc<AuthLimiter>) -> Router {
.route("/page", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(
limiter,
throttle_verification,
fn request(uri: &str, peer: Option<&str>, forwarded: &[&str]) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
for value in forwarded {
builder = builder.header("x-forwarded-for", *value);
let mut req = builder.body(Body::empty()).expect("request builds");
if let Some(peer) = peer {
let addr: SocketAddr = format!("{peer}:45678").parse().expect("peer parses");
req.extensions_mut().insert(ConnectInfo(addr));
req
async fn status(app: &Router, req: Request<Body>) -> StatusCode {
app.clone()
.oneshot(req)
.await
.expect("router responds")
.status()
#[tokio::test]
async fn login_is_served_until_the_budget_runs_out_then_refused() {
let app = app(login_app);
for attempt in 1..=super::MAX_ATTEMPTS {
status(&app, request("/auth/login", Some("203.0.113.7"), &[])).await,
StatusCode::OK,
"attempt {attempt} is within budget"
StatusCode::TOO_MANY_REQUESTS
async fn a_request_with_no_connect_info_is_still_throttled() {
// The extractor is read from extensions rather than required, so a
// request without one must be counted, not waved through or rejected.
for _ in 0..super::MAX_ATTEMPTS {
status(&app, request("/auth/login", None, &[])).await,
StatusCode::OK
async fn every_forwarded_header_line_reaches_the_limiter() {
// Two clients behind the trusted ingress, distinguished only by the
// rightmost hop across BOTH header lines.
let req = request(
"/auth/login",
Some("10.104.0.9"),
&["1.2.3.4", "203.0.113.7"],
assert_eq!(status(&app, req).await, StatusCode::OK);
let exhausted = request(
assert_eq!(status(&app, exhausted).await, StatusCode::TOO_MANY_REQUESTS);
let other = request("/auth/login", Some("10.104.0.9"), &["198.51.100.4"]);
status(&app, other).await,
"a different client behind the same proxy keeps its own budget"
async fn verification_has_its_own_ceiling_on_ordinary_pages() {
let app = app(page_app);
for _ in 0..super::MAX_VERIFICATIONS {
status(&app, request("/page", Some("203.0.113.7"), &[])).await,
status(&app, request("/page", Some("198.51.100.4"), &[])).await,
"the ceiling is per client"