Skip to main content

web/
rate_limit.rs

1//! Throttle for the unauthenticated auth endpoints.
2//!
3//! `POST /auth/login` verifies a password against a hash, so without this it is
4//! an unmetered online password oracle on a public site. The limiter keys on
5//! the resolved client address (see [`crate::client_ip`]) rather than on
6//! anything the request can choose.
7
8use crate::client_ip::{TrustedProxies, throttle_key};
9use axum::extract::{ConnectInfo, Request, State};
10use axum::http::StatusCode;
11use axum::middleware::Next;
12use axum::response::{IntoResponse, Response};
13use ratelimit::{Bucket, Decision, Limits};
14use std::net::{IpAddr, Ipv4Addr, SocketAddr};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18#[cfg(test)]
19mod tests;
20
21/// Attempts one address may make against the auth endpoints per window.
22/// Generous for a human typing a password, cheap against a guessing run.
23const MAX_ATTEMPTS: u32 = 10;
24
25const WINDOW: Duration = Duration::from_secs(60);
26
27/// Addresses tracked at once. Beyond this the least-recently-seen is dropped;
28/// see [`ratelimit`] for why eviction beats refusal.
29const TRACKED_ADDRESSES: usize = 8192;
30
31/// Auth attempts allowed across *all* clients per window.
32///
33/// This is what actually binds when the per-client key is cheap to change.
34/// Rotating addresses defeats the per-client bucket — and, past
35/// [`TRACKED_ADDRESSES`], evicts other clients' counters into fresh budgets —
36/// so without a ceiling on the total the throttle can be walked around by
37/// anyone holding an IPv6 allocation. Set well above real traffic: legitimate
38/// users share it, and exhausting it refuses logins site-wide.
39const GLOBAL_MAX_ATTEMPTS: u32 = 300;
40
41/// Token verifications one address may make per window.
42///
43/// Every authenticated request decodes a JWT, and decoding needs the signing
44/// key of whoever the token *claims* to be — an indexed lookup in the global
45/// directory, performed before anything is authenticated. Attacker-chosen
46/// subjects therefore reach the database freely, and the admin pool that serves
47/// them is five connections wide (ADR 0032).
48///
49/// Far above real traffic, because legitimate users share this with every page
50/// load and HTMX poll. It bounds one source, not a distributed flood; the
51/// remaining defence is that a verification is a single primary-key read.
52const MAX_VERIFICATIONS: u32 = 600;
53
54/// Key used when a request arrives with no peer address.
55///
56/// Should not happen — the server installs `ConnectInfo` — but an
57/// unidentifiable request must still be counted rather than waved through, so
58/// they all share one bucket.
59const UNIDENTIFIED: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
60
61/// Shared throttle state, built once at startup.
62pub struct AuthLimiter {
63    per_client: Mutex<Bucket<IpAddr>>,
64    global: Mutex<Bucket<()>>,
65    verification: Mutex<Bucket<IpAddr>>,
66    trusted: TrustedProxies,
67}
68
69impl AuthLimiter {
70    #[must_use]
71    pub fn new(trusted: TrustedProxies) -> Self {
72        Self {
73            per_client: Mutex::new(Bucket::new(Limits {
74                max_attempts: MAX_ATTEMPTS,
75                window: WINDOW,
76                capacity: TRACKED_ADDRESSES,
77            })),
78            global: Mutex::new(Bucket::new(Limits {
79                max_attempts: GLOBAL_MAX_ATTEMPTS,
80                window: WINDOW,
81                capacity: 1,
82            })),
83            verification: Mutex::new(Bucket::new(Limits {
84                max_attempts: MAX_VERIFICATIONS,
85                window: WINDOW,
86                capacity: TRACKED_ADDRESSES,
87            })),
88            trusted,
89        }
90    }
91
92    /// Records an attempt from `peer`, honouring `forwarded_for` only if the
93    /// peer is a trusted proxy.
94    ///
95    /// The per-client ceiling is charged first, and the global one only if the
96    /// client had budget left.
97    ///
98    /// That order is load-bearing. Charging global first spends it on requests
99    /// that are about to be refused anyway, so one address sitting on its own
100    /// exhausted bucket could drain the site-wide budget and deny login to
101    /// everyone — a cheaper attack than the identity rotation the global ceiling
102    /// exists to stop.
103    ///
104    /// A poisoned lock denies: the limiter failing open would silently restore
105    /// the unmetered oracle this exists to close.
106    pub fn check(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> Decision {
107        let client = self.client_key(peer, forwarded_for);
108
109        let per_client = self
110            .per_client
111            .lock()
112            .map_or(Decision::Deny, |mut bucket| bucket.check(&client));
113
114        if per_client.is_denied() {
115            return Decision::Deny;
116        }
117
118        self.global
119            .lock()
120            .map_or(Decision::Deny, |mut bucket| bucket.check(&()))
121    }
122
123    /// Records a token verification from `peer`.
124    ///
125    /// Separate from the login budget: these are ordinary authenticated
126    /// requests, so the ceiling is far higher and exhausting it must not lock a
127    /// client out of logging in.
128    pub fn check_verification(
129        &self,
130        peer: Option<SocketAddr>,
131        forwarded_for: Option<&str>,
132    ) -> Decision {
133        let client = self.client_key(peer, forwarded_for);
134
135        self.verification
136            .lock()
137            .map_or(Decision::Deny, |mut bucket| bucket.check(&client))
138    }
139
140    fn client_key(&self, peer: Option<SocketAddr>, forwarded_for: Option<&str>) -> IpAddr {
141        peer.map_or(UNIDENTIFIED, |peer| {
142            throttle_key(self.trusted.client_ip(peer, forwarded_for))
143        })
144    }
145}
146
147/// Rejects auth requests from an address that has exceeded its window.
148pub async fn throttle_auth(
149    State(limiter): State<Arc<AuthLimiter>>,
150    request: Request,
151    next: Next,
152) -> Response {
153    let forwarded = crate::client_ip::forwarded_for(request.headers());
154    // Read rather than extracted: a request with no connect info must be
155    // counted, not rejected outright by a failing extractor.
156    let peer = request
157        .extensions()
158        .get::<ConnectInfo<SocketAddr>>()
159        .map(|ConnectInfo(peer)| *peer);
160
161    if limiter.check(peer, forwarded.as_deref()).is_denied() {
162        metrics::counter!(crate::telemetry::AUTH_THROTTLED).increment(1);
163        log::warn!("throttled auth attempt from {peer:?}");
164        return (
165            StatusCode::TOO_MANY_REQUESTS,
166            "Too many attempts. Try again shortly.",
167        )
168            .into_response();
169    }
170
171    next.run(request).await
172}
173
174/// Rejects requests from an address making more token verifications than any
175/// real client would.
176pub async fn throttle_verification(
177    State(limiter): State<Arc<AuthLimiter>>,
178    request: Request,
179    next: Next,
180) -> Response {
181    let forwarded = crate::client_ip::forwarded_for(request.headers());
182    let peer = request
183        .extensions()
184        .get::<ConnectInfo<SocketAddr>>()
185        .map(|ConnectInfo(peer)| *peer);
186
187    if limiter
188        .check_verification(peer, forwarded.as_deref())
189        .is_denied()
190    {
191        metrics::counter!(crate::telemetry::AUTH_THROTTLED).increment(1);
192        log::warn!("throttled token verification from {peer:?}");
193        return (
194            StatusCode::TOO_MANY_REQUESTS,
195            "Too many requests. Try again shortly.",
196        )
197            .into_response();
198    }
199
200    next.run(request).await
201}