churust_ratelimit/lib.rs
1//! Rate limiting for the [Churust] web framework.
2//!
3//! [`RateLimit`] admits a bounded number of requests per key per period and
4//! answers everything above that with `429 Too Many Requests` and a
5//! `Retry-After` header. It is both a [`Plugin`] (server-wide) and a
6//! [`Middleware`] (scoped to part of the route tree with
7//! `RouteBuilder::intercept`).
8//!
9//! ```
10//! use churust_core::{Call, Churust, TestClient};
11//! use churust_ratelimit::RateLimit;
12//!
13//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
14//! let app = Churust::server()
15//! .install(RateLimit::per_minute(2))
16//! .routing(|r| {
17//! r.get("/", |_c: Call| async { "ok" });
18//! })
19//! .build();
20//!
21//! let client = TestClient::new(app);
22//! assert_eq!(client.get("/").send().await.status().as_u16(), 200);
23//! assert_eq!(client.get("/").send().await.status().as_u16(), 200);
24//! let limited = client.get("/").send().await;
25//! assert_eq!(limited.status().as_u16(), 429);
26//! assert!(limited.header("retry-after").is_some());
27//! # });
28//! ```
29//!
30//! # The algorithm
31//!
32//! A generic cell rate algorithm (GCRA), which is a leaky bucket expressed as
33//! one timestamp per key rather than a counter plus a timer. Each key stores a
34//! *theoretical arrival time*: the earliest moment at which the next request
35//! would be perfectly conforming. A request is admitted when it arrives no more
36//! than the burst tolerance ahead of that time, and the timestamp then advances
37//! by one emission interval.
38//!
39//! Two properties follow, and both are why this is preferred over a fixed
40//! window counter. Requests are smoothed rather than admitted in a stampede at
41//! the top of each window, and `Retry-After` is an exact figure that falls out
42//! of the arithmetic instead of an estimate.
43//!
44//! # Choosing a key
45//!
46//! The default key is the connection's peer address, without the port, so
47//! several connections from one client share a bucket. An IPv4 peer is keyed on
48//! the whole address; an IPv6 peer is keyed on its /64 prefix, because the low
49//! 64 bits of an IPv6 address are the interface identifier and the host picks
50//! those itself. Without the mask, a peer that walks its own subnet gets a
51//! fresh allowance per address and is never limited at all, and even an honest
52//! client rotating privacy addresses drifts out of its bucket. The cost of the
53//! mask is that hosts which genuinely share one /64 share a budget; if that is
54//! wrong for your deployment, key on the full address yourself:
55//!
56//! ```
57//! use churust_ratelimit::RateLimit;
58//!
59//! let limiter = RateLimit::per_minute(60)
60//! .by(|call| call.peer_addr().map(|addr| addr.ip().to_string()));
61//! ```
62//!
63//! Behind a reverse proxy the peer address is the proxy, which would put every
64//! visitor in one bucket. Use [`RateLimit::by`] there too, and read a
65//! forwarding header only after checking
66//! [`Call::peer_addr`](churust_core::Call::peer_addr) against proxies you
67//! actually trust. `X-Forwarded-For` is caller-supplied and trivially spoofed.
68//!
69//! [Churust]: churust_core::Churust
70
71#![deny(missing_docs)]
72
73use async_trait::async_trait;
74use churust_core::{AppBuilder, Call, Error, Middleware, Next, Phase, Plugin, Response};
75use http::header::RETRY_AFTER;
76use http::{HeaderValue, StatusCode};
77use std::collections::hash_map::RandomState;
78use std::collections::HashMap;
79use std::hash::BuildHasher;
80use std::net::{IpAddr, Ipv6Addr};
81use std::sync::{Arc, Mutex};
82use std::time::{Duration, Instant};
83
84/// How many keys are tracked before the table is pruned.
85const DEFAULT_MAX_KEYS: usize = 100_000;
86
87/// A function that derives the bucket key for a call, or `None` to exempt it.
88type KeyFn = Arc<dyn Fn(&Call) -> Option<String> + Send + Sync>;
89
90/// The tracked timestamps, behind one lock.
91///
92/// A `HashMap` under a `Mutex` rather than a sharded or lock-free map: the
93/// critical section is a hash lookup and an insert, and a lock held that
94/// briefly is not the bottleneck in a pipeline that is about to do I/O.
95#[derive(Debug, Default)]
96struct Table {
97 /// Key digest to theoretical arrival time.
98 ///
99 /// The digest, not the key. `max_keys` bounds how many entries there are
100 /// and nothing bounds how long a [`KeyFn`] makes one, so storing the key
101 /// itself would let whoever supplies it choose what a bucket costs: a
102 /// header-derived key at a few hundred kilobytes apiece stays far under the
103 /// entry cap, never trips [`Table::prune`], and still pins gigabytes for
104 /// the life of the process. At a fixed width the documented bound holds by
105 /// construction, for every `KeyFn`, without the limiter having to have an
106 /// opinion about what a reasonable key looks like.
107 tat: HashMap<u64, Instant>,
108 /// The seed the digests are taken under.
109 ///
110 /// Two distinct keys that collide share one budget, so the seed is per
111 /// table and randomly chosen rather than fixed: a caller who could compute
112 /// a digest offline could hunt for a value colliding with somebody else's
113 /// key and spend it for them. Against a secret seed that costs the birthday
114 /// work on a 64-bit output with no way to observe a hit, while an accidental
115 /// collision across the default 100,000 live entries sits around one in four
116 /// billion. Truncating the key instead would have been cheaper and would
117 /// have handed that collision to anyone who can type a prefix.
118 seed: RandomState,
119}
120
121impl Table {
122 /// The fixed-width stand-in for a key.
123 fn digest(&self, key: &str) -> u64 {
124 self.seed.hash_one(key)
125 }
126
127 /// Drop keys that have gone idle, then, if the table is still over its cap,
128 /// evict the entries nearest to expiry until it is under.
129 ///
130 /// Evicting a live entry forgives whatever that key had spent, so this is a
131 /// memory bound rather than a security boundary. It only engages when the
132 /// key space is being flooded, which is the case where the alternative,
133 /// unbounded growth, is the more direct denial of service.
134 fn prune(&mut self, now: Instant, max_keys: usize) {
135 self.tat.retain(|_, tat| *tat > now);
136 if self.tat.len() < max_keys {
137 return;
138 }
139 let target = max_keys * 9 / 10;
140 let mut by_expiry: Vec<(u64, Instant)> = self.tat.iter().map(|(k, v)| (*k, *v)).collect();
141 by_expiry.sort_by_key(|(_, tat)| *tat);
142 for (key, _) in by_expiry.into_iter().take(self.tat.len() - target) {
143 self.tat.remove(&key);
144 }
145 }
146}
147
148/// A rate limiter, usable as a plugin or as scoped middleware.
149///
150/// Construct with [`per_second`](RateLimit::per_second),
151/// [`per_minute`](RateLimit::per_minute), [`per_hour`](RateLimit::per_hour) or
152/// [`per`](RateLimit::per), then refine with [`burst`](RateLimit::burst) and
153/// [`by`](RateLimit::by).
154///
155/// Cloning shares the underlying table, so the same limiter can be installed in
156/// several places and still count one budget.
157#[derive(Clone)]
158pub struct RateLimit {
159 /// The spacing between two perfectly conforming requests.
160 emission: Duration,
161 /// How far ahead of its schedule a key may run.
162 tolerance: Duration,
163 /// The advertised allowance, used only for the error message.
164 limit: u32,
165 /// The advertised period, used only for the error message.
166 period: Duration,
167 max_keys: usize,
168 key: KeyFn,
169 table: Arc<Mutex<Table>>,
170}
171
172impl std::fmt::Debug for RateLimit {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("RateLimit")
175 .field("limit", &self.limit)
176 .field("period", &self.period)
177 .field("emission", &self.emission)
178 .field("tolerance", &self.tolerance)
179 .field("max_keys", &self.max_keys)
180 .finish_non_exhaustive()
181 }
182}
183
184impl RateLimit {
185 /// Allow `limit` requests per `period`, per key.
186 ///
187 /// The initial burst equals `limit`: a key that has been quiet may spend
188 /// its whole allowance at once and then refills at `limit / period`. Narrow
189 /// that with [`burst`](RateLimit::burst).
190 ///
191 /// # Panics
192 ///
193 /// If `limit` is zero, or `period` is zero. Both describe a limiter that
194 /// can never admit anything, which is a configuration mistake rather than a
195 /// policy, and failing at startup is the repo's rule for those.
196 pub fn per(limit: u32, period: Duration) -> Self {
197 assert!(limit > 0, "rate limit must admit at least one request");
198 assert!(
199 !period.is_zero(),
200 "rate limit period must be greater than zero"
201 );
202 let emission = period / limit;
203 Self {
204 emission,
205 tolerance: emission * (limit - 1),
206 limit,
207 period,
208 max_keys: DEFAULT_MAX_KEYS,
209 key: Arc::new(default_key),
210 table: Arc::new(Mutex::new(Table::default())),
211 }
212 }
213
214 /// Allow `limit` requests per second, per key.
215 pub fn per_second(limit: u32) -> Self {
216 Self::per(limit, Duration::from_secs(1))
217 }
218
219 /// Allow `limit` requests per minute, per key.
220 pub fn per_minute(limit: u32) -> Self {
221 Self::per(limit, Duration::from_secs(60))
222 }
223
224 /// Allow `limit` requests per hour, per key.
225 pub fn per_hour(limit: u32) -> Self {
226 Self::per(limit, Duration::from_secs(3600))
227 }
228
229 /// Cap the instantaneous burst at `burst` requests.
230 ///
231 /// The sustained rate is unchanged. `burst(1)` admits no burst at all:
232 /// requests must be spaced by a full emission interval.
233 ///
234 /// # Panics
235 ///
236 /// If `burst` is zero.
237 pub fn burst(mut self, burst: u32) -> Self {
238 assert!(burst > 0, "burst must be at least one request");
239 self.tolerance = self.emission * (burst - 1);
240 self
241 }
242
243 /// Derive the bucket key from the call instead of using the peer IP.
244 ///
245 /// Returning `None` exempts the request from limiting entirely, which is
246 /// how you let health checks or an authenticated internal caller through.
247 ///
248 /// The key is hashed and dropped rather than kept, so its length costs
249 /// nothing beyond the one call and a caller-supplied value needs no length
250 /// check of its own before it is handed over.
251 ///
252 /// ```
253 /// use churust_ratelimit::RateLimit;
254 ///
255 /// // Per API key, falling back to no limit for unauthenticated callers.
256 /// let limiter = RateLimit::per_minute(60)
257 /// .by(|call| call.header("x-api-key").map(str::to_owned));
258 /// ```
259 pub fn by<F>(mut self, f: F) -> Self
260 where
261 F: Fn(&Call) -> Option<String> + Send + Sync + 'static,
262 {
263 self.key = Arc::new(f);
264 self
265 }
266
267 /// Set how many keys are tracked before the table is pruned.
268 ///
269 /// The table holds a 64-bit digest of the key and one timestamp per active
270 /// client, so an entry costs the same whatever the key is and the default of
271 /// 100,000 is a few megabytes for any [`by`](RateLimit::by) function. Raise
272 /// it for a large fleet, lower it for a memory-constrained deployment.
273 ///
274 /// # Panics
275 ///
276 /// If `n` is zero.
277 pub fn max_keys(mut self, n: usize) -> Self {
278 assert!(n > 0, "max_keys must be at least one");
279 self.max_keys = n;
280 self
281 }
282
283 /// Test one request against the limiter.
284 ///
285 /// `Ok(())` admits it. `Err(delay)` rejects it, where `delay` is how long
286 /// the caller must wait before a request would conform.
287 fn check(&self, key: &str) -> Result<(), Duration> {
288 let now = Instant::now();
289 let mut table = self.table.lock().unwrap_or_else(|p| p.into_inner());
290
291 if table.tat.len() >= self.max_keys {
292 table.prune(now, self.max_keys);
293 }
294
295 let digest = table.digest(key);
296 let tat = table.tat.get(&digest).copied().unwrap_or(now);
297 // The earliest arrival this key may make. `checked_sub` failing means
298 // the tolerance reaches back past the process's own clock origin, which
299 // can only mean the key is idle, so the request conforms.
300 let earliest = tat.checked_sub(self.tolerance).unwrap_or(now);
301 if now < earliest {
302 return Err(earliest - now);
303 }
304
305 // A key that has fallen behind schedule resumes from now rather than
306 // accumulating credit for the time it was quiet. That credit is what
307 // the burst tolerance already expresses.
308 let base = if tat > now { tat } else { now };
309 table.tat.insert(digest, base + self.emission);
310 Ok(())
311 }
312
313 /// The response for a rejected request.
314 ///
315 /// `Retry-After` is whole seconds per RFC 9110 §10.2.3, rounded up so the
316 /// advertised moment is never earlier than the conforming one, and floored
317 /// at one so a sub-second wait does not read as "retry immediately".
318 fn too_many(&self, delay: Duration) -> Response {
319 let secs = delay.as_secs_f64().ceil().max(1.0) as u64;
320 let message = format!(
321 "rate limit exceeded: {} requests per {} seconds",
322 self.limit,
323 self.period.as_secs_f64()
324 );
325 let mut error = Error::new(StatusCode::TOO_MANY_REQUESTS, message);
326 if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
327 error = error.with_response_header(RETRY_AFTER, value);
328 }
329 churust_core::IntoResponse::into_response(error)
330 }
331}
332
333/// The peer address without its port, so several connections from one client
334/// share a bucket.
335///
336/// A call with no peer address (the in-process test client, or a transport that
337/// does not carry one) falls into a single shared bucket rather than being
338/// exempted. Exempting would make "arrive without an address" the way around
339/// the limiter.
340fn default_key(call: &Call) -> Option<String> {
341 Some(match call.peer_addr() {
342 Some(addr) => address_bucket(addr.ip()),
343 None => "unknown".to_string(),
344 })
345}
346
347/// The bucket an address belongs to: the whole address for IPv4, the /64 prefix
348/// for IPv6.
349///
350/// Keying on the full IPv6 address was the obvious reading of "one bucket per
351/// client" and it was the wrong one, because an IPv6 address does not name a
352/// client the way an IPv4 address does. The smallest allocation anybody is
353/// delegated is a /64 — RFC 4291 §2.5.1 spends the low half of every unicast
354/// address on an interface identifier, and SLAAC has the host mint those itself
355/// — so the low 64 bits are chosen by the peer, for free, as often as it likes.
356/// That broke the limiter in both directions. An attacker took a fresh address
357/// per request and never met a 429, because every request missed the table,
358/// defaulted its arrival time to `now` and conformed; and an ordinary laptop
359/// running RFC 8981 temporary addresses silently earned a new allowance every
360/// time it rotated. Masking to the /64 keys on the part of the address that is
361/// routed to the peer rather than the part the peer writes itself, which is the
362/// only half that costs anything to change.
363///
364/// A /64 and no coarser. Aggregating to a /56 or /48 would bucket by delegation
365/// rather than by subnet, and delegation sizes are a matter of ISP taste, so it
366/// would fold strangers together at some providers to catch a rotation that a
367/// /64 already catches. The residual is that hosts sharing one provider's /64 —
368/// virtual machines handed single addresses out of a rack prefix, say — share a
369/// bucket; that is the trade IPv4 has always made behind NAT, and a deployment
370/// that cannot afford it keys on something else with [`RateLimit::by`].
371///
372/// IPv4-mapped addresses are unwrapped before any of that. A dual-stack
373/// listener reports IPv4 peers as `::ffff:a.b.c.d`, and every one of those sits
374/// in `::ffff:0:0/96` — inside a single /64 — so masking them would have put
375/// the entire IPv4 internet in one bucket and turned the fix into a far worse
376/// defect than the one it repairs.
377fn address_bucket(ip: IpAddr) -> String {
378 let v6 = match ip {
379 IpAddr::V4(v4) => return v4.to_string(),
380 IpAddr::V6(v6) => v6,
381 };
382 if let Some(v4) = v6.to_ipv4_mapped() {
383 return v4.to_string();
384 }
385 let mut octets = v6.octets();
386 octets[8..].fill(0);
387 Ipv6Addr::from(octets).to_string()
388}
389
390#[async_trait]
391impl Middleware for RateLimit {
392 async fn handle(&self, call: Call, next: Next) -> Response {
393 let Some(key) = (self.key)(&call) else {
394 return next.run(call).await;
395 };
396 match self.check(&key) {
397 Ok(()) => next.run(call).await,
398 Err(delay) => self.too_many(delay),
399 }
400 }
401}
402
403impl Plugin for RateLimit {
404 /// Installed in [`Phase::Plugins`], so a rejected request is still logged by
405 /// a `CallLogging` plugin sitting in [`Phase::Monitoring`] outside it.
406 fn install(self: Box<Self>, app: &mut AppBuilder) {
407 app.add_middleware_in(Phase::Plugins, Arc::new(*self));
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn a_fresh_key_may_spend_the_whole_allowance_at_once() {
417 let limiter = RateLimit::per(3, Duration::from_secs(10));
418 assert!(limiter.check("a").is_ok());
419 assert!(limiter.check("a").is_ok());
420 assert!(limiter.check("a").is_ok());
421 assert!(limiter.check("a").is_err(), "the fourth exceeds the burst");
422 }
423
424 #[test]
425 fn keys_are_independent() {
426 let limiter = RateLimit::per(1, Duration::from_secs(10));
427 assert!(limiter.check("a").is_ok());
428 assert!(limiter.check("a").is_err());
429 assert!(limiter.check("b").is_ok(), "b has its own budget");
430 }
431
432 #[test]
433 fn burst_one_admits_a_single_request() {
434 let limiter = RateLimit::per(100, Duration::from_secs(1)).burst(1);
435 assert!(limiter.check("a").is_ok());
436 assert!(limiter.check("a").is_err());
437 }
438
439 #[test]
440 fn the_reported_delay_never_exceeds_the_period() {
441 let limiter = RateLimit::per(2, Duration::from_secs(4));
442 assert!(limiter.check("a").is_ok());
443 assert!(limiter.check("a").is_ok());
444 let delay = limiter.check("a").expect_err("should be limited");
445 assert!(
446 delay <= Duration::from_secs(4),
447 "waiting longer than the period would be wrong: {delay:?}"
448 );
449 }
450
451 #[test]
452 fn clones_share_one_budget() {
453 let limiter = RateLimit::per(1, Duration::from_secs(10));
454 let clone = limiter.clone();
455 assert!(limiter.check("a").is_ok());
456 assert!(
457 clone.check("a").is_err(),
458 "a clone must not reset the table"
459 );
460 }
461
462 #[test]
463 fn pruning_bounds_the_table() {
464 let limiter = RateLimit::per(1, Duration::from_millis(1)).max_keys(8);
465 for i in 0..64 {
466 let _ = limiter.check(&format!("key-{i}"));
467 }
468 let len = limiter.table.lock().unwrap().tat.len();
469 assert!(len <= 8, "table grew past its cap: {len}");
470 }
471
472 #[test]
473 fn a_long_key_is_stored_at_the_same_width_as_a_short_one() {
474 // A key that comes out of a header is chosen by the caller in length as
475 // well as in value, and `max_keys` counts entries rather than bytes, so
476 // nothing else in this file stops one client from deciding what a
477 // bucket costs. Debug renders precisely what an entry holds, which
478 // makes the rendered length a proxy for the entry's footprint that does
479 // not depend on knowing the key type.
480 let limiter = RateLimit::per(2, Duration::from_secs(30));
481 let long = "k".repeat(1 << 20);
482 assert!(limiter.check("k").is_ok());
483 assert!(limiter.check(&long).is_ok());
484
485 let table = limiter.table.lock().unwrap();
486 assert_eq!(table.tat.len(), 2, "the two keys are distinct");
487 let rendered = format!("{:?}", table.tat);
488 assert!(
489 rendered.len() < 256,
490 "the table retained the key's {} bytes instead of a fixed-width \
491 digest, so a caller picks the cost of its own bucket",
492 long.len()
493 );
494 }
495
496 #[test]
497 fn a_client_rotating_through_one_ipv6_subnet_gets_no_extra_budget() {
498 let limiter = RateLimit::per(2, Duration::from_secs(30));
499 let rotate = |suffix: &str| {
500 let ip: IpAddr = format!("2001:db8:1:2::{suffix}").parse().unwrap();
501 limiter.check(&address_bucket(ip))
502 };
503 assert!(rotate("1").is_ok());
504 assert!(rotate("2").is_ok());
505 assert!(
506 rotate("3").is_err(),
507 "a new address out of the same /64 is the same client and must not \
508 reset the budget"
509 );
510 }
511
512 #[test]
513 fn separate_ipv6_subnets_keep_separate_budgets() {
514 let one: IpAddr = "2001:db8:1:2::1".parse().unwrap();
515 let other: IpAddr = "2001:db8:1:3::1".parse().unwrap();
516 assert_ne!(
517 address_bucket(one),
518 address_bucket(other),
519 "different /64s are different networks"
520 );
521 }
522
523 #[test]
524 fn an_ipv4_peer_keeps_every_octet() {
525 let ip: IpAddr = "203.0.113.7".parse().unwrap();
526 assert_eq!(address_bucket(ip), "203.0.113.7");
527 }
528
529 #[test]
530 fn an_ipv4_mapped_peer_is_bucketed_as_the_address_it_carries() {
531 let mapped: IpAddr = "::ffff:203.0.113.7".parse().unwrap();
532 let neighbour: IpAddr = "::ffff:203.0.113.8".parse().unwrap();
533 assert_eq!(address_bucket(mapped), "203.0.113.7");
534 assert_ne!(
535 address_bucket(mapped),
536 address_bucket(neighbour),
537 "a dual-stack socket reports IPv4 peers inside one /64, so masking \
538 them would put the whole IPv4 internet in a single bucket"
539 );
540 }
541
542 #[test]
543 #[should_panic(expected = "at least one request")]
544 fn a_zero_limit_is_a_configuration_error() {
545 let _ = RateLimit::per(0, Duration::from_secs(1));
546 }
547
548 #[test]
549 #[should_panic(expected = "greater than zero")]
550 fn a_zero_period_is_a_configuration_error() {
551 let _ = RateLimit::per(1, Duration::ZERO);
552 }
553}