Lines
100 %
Functions
64.58 %
Branches
//! Throttle for the unauthenticated auth endpoints.
//!
//! `POST /auth/login` verifies a password against a hash, so without this it is
//! an unmetered online password oracle on a public site. The limiter keys on
//! the resolved client address (see [`crate::client_ip`]) rather than on
//! anything the request can choose.
use crate::client_ip::{TrustedProxies, throttle_key};
use axum::extract::{ConnectInfo, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use ratelimit::{Bucket, Decision, Limits};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[cfg(test)]
mod tests;
/// Attempts one address may make against the auth endpoints per window.
/// Generous for a human typing a password, cheap against a guessing run.
const MAX_ATTEMPTS: u32 = 10;
const WINDOW: Duration = Duration::from_secs(60);
/// Addresses tracked at once. Beyond this the least-recently-seen is dropped;
/// see [`ratelimit`] for why eviction beats refusal.
const TRACKED_ADDRESSES: usize = 8192;
/// Auth attempts allowed across *all* clients per window.
///
/// This is what actually binds when the per-client key is cheap to change.
/// Rotating addresses defeats the per-client bucket — and, past
/// [`TRACKED_ADDRESSES`], evicts other clients' counters into fresh budgets —
/// so without a ceiling on the total the throttle can be walked around by
/// anyone holding an IPv6 allocation. Set well above real traffic: legitimate
/// users share it, and exhausting it refuses logins site-wide.
const GLOBAL_MAX_ATTEMPTS: u32 = 300;
/// Token verifications one address may make per window.
/// Every authenticated request decodes a JWT, and decoding needs the signing
/// key of whoever the token *claims* to be — an indexed lookup in the global
/// directory, performed before anything is authenticated. Attacker-chosen
/// subjects therefore reach the database freely, and the admin pool that serves
/// them is five connections wide (ADR 0032).
/// Far above real traffic, because legitimate users share this with every page
/// load and HTMX poll. It bounds one source, not a distributed flood; the
/// remaining defence is that a verification is a single primary-key read.
const MAX_VERIFICATIONS: u32 = 600;
/// Key used when a request arrives with no peer address.
/// Should not happen — the server installs `ConnectInfo` — but an
/// unidentifiable request must still be counted rather than waved through, so
/// they all share one bucket.
const UNIDENTIFIED: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
/// Shared throttle state, built once at startup.
pub struct AuthLimiter {
per_client: Mutex<Bucket<IpAddr>>,
global: Mutex<Bucket<()>>,
verification: Mutex<Bucket<IpAddr>>,
trusted: TrustedProxies,
}
impl AuthLimiter {
#[must_use]
pub fn new(trusted: TrustedProxies) -> Self {
Self {
per_client: Mutex::new(Bucket::new(Limits {
max_attempts: MAX_ATTEMPTS,
window: WINDOW,
capacity: TRACKED_ADDRESSES,
})),
global: Mutex::new(Bucket::new(Limits {
max_attempts: GLOBAL_MAX_ATTEMPTS,
capacity: 1,
verification: Mutex::new(Bucket::new(Limits {
max_attempts: MAX_VERIFICATIONS,
trusted,
/// Records an attempt from `peer`, honouring `forwarded_for` only if the
/// peer is a trusted proxy.
/// The per-client ceiling is charged first, and the global one only if the
/// client had budget left.
/// That order is load-bearing. Charging global first spends it on requests
/// that are about to be refused anyway, so one address sitting on its own
/// exhausted bucket could drain the site-wide budget and deny login to
/// everyone — a cheaper attack than the identity rotation the global ceiling
/// exists to stop.
/// A poisoned lock denies: the limiter failing open would silently restore
/// the unmetered oracle this exists to close.
pub fn check(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> Decision {
let client = self.client_key(peer, forwarded_for);
let per_client = self
.per_client
.lock()
.map_or(Decision::Deny, |mut bucket| bucket.check(&client));
if per_client.is_denied() {
return Decision::Deny;
self.global
.map_or(Decision::Deny, |mut bucket| bucket.check(&()))
/// Records a token verification from `peer`.
/// Separate from the login budget: these are ordinary authenticated
/// requests, so the ceiling is far higher and exhausting it must not lock a
/// client out of logging in.
pub fn check_verification(
&self,
peer: Option<SocketAddr>,
forwarded_for: Option<&str>,
) -> Decision {
self.verification
.map_or(Decision::Deny, |mut bucket| bucket.check(&client))
fn client_key(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> IpAddr {
peer.map_or(UNIDENTIFIED, |peer| {
throttle_key(self.trusted.client_ip(peer, forwarded_for))
})
/// Rejects auth requests from an address that has exceeded its window.
pub async fn throttle_auth(
State(limiter): State<Arc<AuthLimiter>>,
request: Request,
next: Next,
) -> Response {
let forwarded = crate::client_ip::forwarded_for(request.headers());
// Read rather than extracted: a request with no connect info must be
// counted, not rejected outright by a failing extractor.
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(peer)| *peer);
if limiter.check(peer, forwarded.as_deref()).is_denied() {
metrics::counter!(crate::telemetry::AUTH_THROTTLED).increment(1);
log::warn!("throttled auth attempt from {peer:?}");
return (
StatusCode::TOO_MANY_REQUESTS,
"Too many attempts. Try again shortly.",
)
.into_response();
next.run(request).await
/// Rejects requests from an address making more token verifications than any
/// real client would.
pub async fn throttle_verification(
if limiter
.check_verification(peer, forwarded.as_deref())
.is_denied()
{
log::warn!("throttled token verification from {peer:?}");
"Too many requests. Try again shortly.",