didbot_serve/rate_limit.rs
1//! A minimal fixed-window rate limiter, shared by every anonymous route that
2//! does real work before it knows who is calling.
3//!
4//! `bot.did.createAccount` and `POST /oauth/par` are the motivating cases:
5//! the first mints a signing key and a hostname, the second fetches a
6//! document from a host the caller names, and both do it for a caller this
7//! server has not authenticated yet. This limiter is what stands in front of
8//! that cost.
9//!
10//! A small fixed-window counter: one `HashMap`, one lock, no background
11//! task. The per-account budget on `atproto-proxy` is a `governor` token
12//! bucket instead; see `crate::proxy`.
13//!
14//! Fixed-window rather than a token bucket: it under-approximates a true rate
15//! at the window boundary (a caller can burst up to `limit` twice, once at
16//! the end of one window and once at the start of the next), which is an
17//! acceptable trade for a limiter this small.
18//!
19//! # A caller's name
20//!
21//! [`caller_key`] defaults to the TCP peer address — the socket's own
22//! `SocketAddr`, delivered through `axum`'s `ConnectInfo` extractor, wired at
23//! [`serve_router`](crate::serve_router) and
24//! [`serve_tls`](crate::serve_tls) — because that is the one address a
25//! caller cannot lie about. `didbot-tls` terminates TLS itself with no
26//! reverse proxy in front of it, so `X-Forwarded-For` and `X-Real-IP` are, by
27//! default, ordinary caller-supplied header values: trusting them
28//! unconditionally would let a caller mint a fresh budget on every request
29//! just by sending a different header, which is not a degraded limiter but a
30//! bypassed one. [`caller_key`] only reads those headers when
31//! `trust_forwarded_headers` is `true` — an explicit, off-by-default setting
32//! an operator turns on after putting a real reverse proxy in front of this
33//! server. Nothing here infers that posture from a header's mere presence.
34//!
35//! Turned on, the *rightmost* `X-Forwarded-For` element is read: a proxy
36//! appends its own observation to whatever the client sent, so the right-hand
37//! end is the proxy's and the left-hand end is the client's. This holds for
38//! the single trusted proxy a deployment puts in front of one server; a chain
39//! of several needs a setting naming how far to walk, and there is none.
40//!
41//! A caller reached with no [`ConnectInfo`] at
42//! all — a test harness driving a [`Router`](axum::Router) directly, or a
43//! deployment that has not wired [`serve_router`](crate::serve_router) or
44//! [`serve_tls`](crate::serve_tls) — shares the same `"unknown"` bucket
45//! every caller got before this address was available, rather than failing
46//! the request.
47//!
48
49use std::collections::HashMap;
50use std::convert::Infallible;
51use std::net::{IpAddr, Ipv6Addr, SocketAddr};
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use axum::extract::connect_info::ConnectInfo;
56use axum::extract::FromRequestParts;
57use axum::http::request::Parts;
58use axum::http::HeaderMap;
59
60/// A handler's own extraction of the caller's TCP peer address, for
61/// [`caller_key`].
62///
63/// Unlike `Option<ConnectInfo<SocketAddr>>`, this never fails to extract:
64/// `axum::extract::connect_info::ConnectInfo` does not implement axum's
65/// `OptionalFromRequestParts`, so wrapping it in `Option` in a handler
66/// signature is a compile error, not a graceful `None`. [`Peer`] reads the
67/// same [`ConnectInfo`] extension directly and answers `None` when it is
68/// absent — a test driving a [`Router`](axum::Router) with
69/// [`tower::ServiceExt::oneshot`], or a deployment that has not wired
70/// [`crate::serve_router`]/[`crate::serve_tls`] — rather than rejecting the
71/// request.
72pub struct Peer(pub Option<IpAddr>);
73
74impl<S> FromRequestParts<S> for Peer
75where
76 S: Send + Sync,
77{
78 type Rejection = Infallible;
79
80 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
81 Ok(Peer(
82 parts
83 .extensions
84 .get::<ConnectInfo<SocketAddr>>()
85 .map(|ConnectInfo(addr)| addr.ip()),
86 ))
87 }
88}
89
90/// The default window a caller's request count is measured over.
91///
92/// Not a deployment's to set yet: `plan/config.md` holds `[limits]` as
93/// schema for this and `oauth/rate_limit.rs`'s own budgets, deferred until
94/// the two converge on one shape rather than adding a key each now that a
95/// later pass would have to reconcile.
96pub const WINDOW: Duration = Duration::from_secs(60);
97
98/// The default number of requests one key may make per [`WINDOW`], for a
99/// limiter that does not pick its own. See [`WINDOW`] for why this is not a
100/// key today.
101pub const DEFAULT_LIMIT: u32 = 30;
102
103/// The longest key that gets a bucket of its own.
104///
105/// [`caller_key`] answers an address, but a limiter keyed on something the
106/// caller *sends* is handed an arbitrary string by an unauthenticated
107/// caller. `MAX_DID_LENGTH` is the
108/// longest an atproto identifier can be at all: a handle caps at 253 bytes
109/// and a DID at this, so nothing longer than this resolves to an account on
110/// any deployment. Keys past it are therefore not identifiers being checked,
111/// and giving each one a bucket would mean retaining a caller-chosen string
112/// per request in a map with no other bound on it.
113pub const MAX_KEY_LENGTH: usize = didbot_identity::MAX_DID_LENGTH;
114
115/// The single bucket every key over [`MAX_KEY_LENGTH`] shares.
116///
117/// The same answer [`caller_key`] already gives a caller it cannot tell
118/// apart from another: one shared budget rather than a free one. Sharing is
119/// strictly the stricter reading — the keys collapsed into it cannot name an
120/// account, so nothing legitimate is being made to queue behind anything
121/// else — and it is what keeps a key's *content* out of this map.
122pub const OVER_LONG_KEY: &str = "over-long";
123
124/// The key a bucket is actually filed under: `key` itself, or the shared
125/// [`OVER_LONG_KEY`] when it is longer than [`MAX_KEY_LENGTH`].
126fn bucket_key(key: &str) -> &str {
127 if key.len() > MAX_KEY_LENGTH {
128 OVER_LONG_KEY
129 } else {
130 key
131 }
132}
133
134struct Bucket {
135 window_started_at: Instant,
136 count: u32,
137}
138
139/// Counts requests per key (typically a caller's address, from
140/// [`caller_key`]) within a fixed window, and says whether the next one is
141/// still allowed.
142pub struct RateLimiter {
143 limit: u32,
144 window: Duration,
145 state: Mutex<State>,
146}
147
148/// The buckets, and when they were last swept for elapsed windows.
149struct State {
150 buckets: HashMap<String, Bucket>,
151 last_swept: Instant,
152}
153
154impl RateLimiter {
155 /// A limiter allowing `limit` requests per `window`, per key.
156 pub fn new(limit: u32, window: Duration) -> Self {
157 Self {
158 limit,
159 window,
160 state: Mutex::new(State {
161 buckets: HashMap::new(),
162 last_swept: Instant::now(),
163 }),
164 }
165 }
166
167 /// Records one request for `key` and reports whether it is within the
168 /// limit. A key seen for the first time, or one whose window has
169 /// elapsed, starts a fresh window.
170 ///
171 /// A key over [`MAX_KEY_LENGTH`] is counted against the one shared
172 /// [`OVER_LONG_KEY`] bucket rather than a bucket of its own, and buckets
173 /// whose windows have elapsed are dropped rather than kept — see both
174 /// items' own docs for why neither changes what this answers.
175 pub fn check(&self, key: &str) -> bool {
176 let now = Instant::now();
177 let key = bucket_key(key);
178 let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
179 // An elapsed window is reset to zero the moment it is next touched,
180 // so a bucket already past its window says exactly what an absent
181 // one says. Dropping those is what keeps this map's size a function
182 // of recent callers rather than of every key ever seen.
183 //
184 // Swept once per window rather than on every call, because a scan
185 // per request would make one caller's cost grow with how many other
186 // callers there are — an amplification of its own, and the shape of
187 // the one this sweep exists to close. Amortized this way a bucket
188 // outlives its window by at most one more, and the scan it costs is
189 // paid once across every request in that window.
190 if now.duration_since(state.last_swept) >= self.window {
191 let window = self.window;
192 state
193 .buckets
194 .retain(|_, bucket| now.duration_since(bucket.window_started_at) < window);
195 state.last_swept = now;
196 }
197 let bucket = state.buckets.entry(key.to_owned()).or_insert(Bucket {
198 window_started_at: now,
199 count: 0,
200 });
201 if now.duration_since(bucket.window_started_at) >= self.window {
202 bucket.window_started_at = now;
203 bucket.count = 0;
204 }
205 bucket.count += 1;
206 bucket.count <= self.limit
207 }
208
209 /// How many keys this limiter is currently holding a bucket for.
210 ///
211 /// The limiter's retained footprint, for a test that needs to assert on
212 /// it and for anything that later wants to log it. A bucket whose window
213 /// has elapsed but which the sweep in [`check`](Self::check) has not
214 /// reached yet still counts.
215 pub fn tracked_keys(&self) -> usize {
216 self.state
217 .lock()
218 .unwrap_or_else(|p| p.into_inner())
219 .buckets
220 .len()
221 }
222}
223
224impl Default for RateLimiter {
225 fn default() -> Self {
226 Self::new(DEFAULT_LIMIT, WINDOW)
227 }
228}
229
230/// Names the caller a [`RateLimiter`] should key its budget on.
231///
232/// `peer` is the TCP socket's own address — see this module's doc for how a
233/// handler gets one — and is what this returns unless `trust_forwarded_headers`
234/// says to read `X-Forwarded-For`/`X-Real-IP` instead. `peer` being `None`
235/// (no [`ConnectInfo`] reached the handler) and
236/// `trust_forwarded_headers` being `true` with neither header present both
237/// answer the same `"unknown"`, the shared bucket every caller falls back to
238/// when this cannot tell them apart.
239///
240/// An IPv6 address is keyed on its /64, because one host routinely holds a
241/// whole /64. An IPv4-mapped IPv6 address is keyed as its IPv4 address. A
242/// forwarded value that is not an address at all leaves the caller on the TCP
243/// peer's budget, so a caller cannot open a bucket of its own by inventing a
244/// string to be keyed under.
245pub fn caller_key(
246 headers: &HeaderMap,
247 peer: Option<IpAddr>,
248 trust_forwarded_headers: bool,
249) -> String {
250 if trust_forwarded_headers {
251 if let Some(observed) = forwarded_caller(headers) {
252 return address_key(observed);
253 }
254 }
255 peer.map(address_key)
256 .unwrap_or_else(|| "unknown".to_owned())
257}
258
259/// The budget key for one address: see [`caller_key`].
260fn address_key(ip: IpAddr) -> String {
261 match ip.to_canonical() {
262 IpAddr::V4(v4) => v4.to_string(),
263 IpAddr::V6(v6) => {
264 let prefix = Ipv6Addr::from_bits(v6.to_bits() & !(u128::MAX >> 64));
265 format!("{prefix}/64")
266 }
267 }
268}
269
270/// The address the reverse proxy in front of this server saw the request
271/// arrive from, for a deployment that has opted into trusting it.
272///
273/// The **rightmost** `X-Forwarded-For` element, because a proxy appends:
274/// nginx's `proxy_add_x_forwarded_for` and an AWS ALB both put the peer they
275/// saw at the right-hand end and keep whatever the client sent to its left.
276/// The leftmost element is therefore the client's own text, and reading it is
277/// what let a caller send a different value on every request and get a fresh
278/// budget against every limiter each time.
279///
280/// `None` when neither header holds an address: an unparseable value is not a
281/// caller's name, and the TCP peer is.
282fn forwarded_caller(headers: &HeaderMap) -> Option<IpAddr> {
283 let element = |name: &str, take: fn(&str) -> Option<&str>| {
284 headers
285 .get(name)
286 .and_then(|value| value.to_str().ok())
287 .and_then(take)
288 .map(str::trim)
289 .filter(|value| !value.is_empty())
290 .and_then(|value| value.parse::<IpAddr>().ok())
291 };
292 element("x-forwarded-for", |value| value.rsplit(',').next())
293 .or_else(|| element("x-real-ip", |value| Some(value)))
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn requests_within_the_limit_are_allowed() {
302 let limiter = RateLimiter::new(3, Duration::from_secs(60));
303 assert!(limiter.check("1.2.3.4"));
304 assert!(limiter.check("1.2.3.4"));
305 assert!(limiter.check("1.2.3.4"));
306 }
307
308 #[test]
309 fn a_request_past_the_limit_is_refused() {
310 let limiter = RateLimiter::new(2, Duration::from_secs(60));
311 assert!(limiter.check("1.2.3.4"));
312 assert!(limiter.check("1.2.3.4"));
313 assert!(!limiter.check("1.2.3.4"));
314 }
315
316 #[test]
317 fn different_keys_have_independent_budgets() {
318 let limiter = RateLimiter::new(1, Duration::from_secs(60));
319 assert!(limiter.check("1.2.3.4"));
320 assert!(limiter.check("5.6.7.8"));
321 assert!(!limiter.check("1.2.3.4"));
322 }
323
324 #[test]
325 fn a_window_reset_allows_more_requests() {
326 let limiter = RateLimiter::new(1, Duration::from_millis(10));
327 assert!(limiter.check("1.2.3.4"));
328 assert!(!limiter.check("1.2.3.4"));
329 std::thread::sleep(Duration::from_millis(20));
330 assert!(limiter.check("1.2.3.4"));
331 }
332
333 #[test]
334 fn buckets_for_elapsed_windows_are_not_retained() {
335 let limiter = RateLimiter::new(5, Duration::from_millis(10));
336 for i in 0..50 {
337 assert!(limiter.check(&format!("key-{i}")));
338 }
339 assert_eq!(limiter.tracked_keys(), 50);
340 std::thread::sleep(Duration::from_millis(20));
341 // Every window above has elapsed, so every one of those buckets now
342 // says exactly what a key this limiter has never seen says. Touching
343 // one more key must not leave the other 50 resident.
344 assert!(limiter.check("key-fresh"));
345 assert_eq!(limiter.tracked_keys(), 1);
346 }
347
348 #[test]
349 fn an_over_long_key_is_not_retained_verbatim() {
350 let limiter = RateLimiter::new(5, Duration::from_secs(60));
351 // An identifier no handle and no DID could ever be. Nothing this
352 // long resolves to an account, so it must not buy the caller a
353 // bucket of its own — let alone one holding a copy of it.
354 let huge = "z".repeat(64 * 1024);
355 assert!(limiter.check(&huge));
356 let other = "y".repeat(64 * 1024);
357 assert!(limiter.check(&other));
358 // Both collapse into the one shared over-long bucket.
359 assert_eq!(limiter.tracked_keys(), 1);
360 }
361
362 #[test]
363 fn caller_key_defaults_to_the_peer_address_and_ignores_forwarded_headers() {
364 let mut headers = HeaderMap::new();
365 headers.insert("x-forwarded-for", "9.9.9.9, 10.0.0.1".parse().unwrap());
366 headers.insert("x-real-ip", "8.8.8.8".parse().unwrap());
367 let peer: IpAddr = "203.0.113.9".parse().unwrap();
368 // `trust_forwarded_headers` is false: a caller-supplied header,
369 // however it is shaped, must not override the real peer address.
370 assert_eq!(caller_key(&headers, Some(peer), false), "203.0.113.9");
371 }
372
373 #[test]
374 fn caller_key_shares_one_bucket_with_no_peer_and_no_trust() {
375 let headers = HeaderMap::new();
376 assert_eq!(caller_key(&headers, None, false), "unknown");
377 }
378
379 /// The rightmost element, which is the proxy's own observation: a proxy
380 /// appends, so everything to its left is text the client sent.
381 #[test]
382 fn caller_key_reads_forwarded_headers_only_when_trusted() {
383 let mut headers = HeaderMap::new();
384 headers.insert("x-forwarded-for", "9.9.9.9, 10.0.0.1".parse().unwrap());
385 let peer: IpAddr = "203.0.113.9".parse().unwrap();
386 assert_eq!(caller_key(&headers, Some(peer), true), "10.0.0.1");
387 }
388
389 /// A caller that writes its own `X-Forwarded-For` gets one budget, not a
390 /// fresh one per request: the proxy's appended element is what is read,
391 /// and it is the same every time.
392 #[test]
393 fn a_forged_forwarded_prefix_does_not_mint_a_budget_per_request() {
394 let proxy: IpAddr = "10.0.0.1".parse().unwrap();
395 let forged = |claim: &str| {
396 let mut headers = HeaderMap::new();
397 headers.insert(
398 "x-forwarded-for",
399 format!("{claim}, {proxy}").parse().unwrap(),
400 );
401 caller_key(&headers, Some("203.0.113.9".parse().unwrap()), true)
402 };
403 assert_eq!(forged("9.9.9.9"), forged("198.51.100.7"));
404 assert_eq!(forged("a"), forged("9.9.9.9"));
405 assert_eq!(forged("9.9.9.9"), "10.0.0.1");
406 }
407
408 /// A value that is not an address is not a caller's name. Keying it
409 /// verbatim gave anything a caller cared to type a bucket of its own.
410 #[test]
411 fn an_unparseable_forwarded_value_leaves_the_caller_on_the_peer() {
412 let peer: IpAddr = "203.0.113.9".parse().unwrap();
413 let mut headers = HeaderMap::new();
414 headers.insert("x-forwarded-for", "not-an-address".parse().unwrap());
415 assert_eq!(caller_key(&headers, Some(peer), true), "203.0.113.9");
416 assert_eq!(caller_key(&headers, None, true), "unknown");
417 }
418
419 #[test]
420 fn caller_key_falls_back_to_x_real_ip_when_trusted() {
421 let mut headers = HeaderMap::new();
422 headers.insert("x-real-ip", "8.8.8.8".parse().unwrap());
423 assert_eq!(caller_key(&headers, None, true), "8.8.8.8");
424 }
425
426 #[test]
427 fn caller_key_shares_one_bucket_when_trusted_with_no_headers_or_peer() {
428 let headers = HeaderMap::new();
429 assert_eq!(caller_key(&headers, None, true), "unknown");
430 }
431
432 /// One host can pick any address in its /64, so a per-address key would
433 /// hand it an unlimited number of budgets.
434 #[test]
435 fn caller_key_shares_one_budget_across_an_ipv6_64() {
436 let headers = HeaderMap::new();
437 let key = |ip: &str| caller_key(&headers, Some(ip.parse().unwrap()), false);
438 assert_eq!(
439 key("2001:db8:1:2::1"),
440 key("2001:db8:1:2:ffff:ffff:ffff:ffff")
441 );
442 assert_ne!(key("2001:db8:1:2::1"), key("2001:db8:1:3::1"));
443 assert_eq!(key("::ffff:203.0.113.9"), "203.0.113.9");
444
445 let mut forwarded = HeaderMap::new();
446 forwarded.insert("x-forwarded-for", "2001:db8:1:2::9".parse().unwrap());
447 assert_eq!(caller_key(&forwarded, None, true), key("2001:db8:1:2::1"));
448 }
449}