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}