plecto_control/ratelimit.rs
1//! Native fast-path rate limiting (ADR 000033).
2//!
3//! A coarse token-bucket baseline the fast path consults BEFORE a route's filter chain, so a flood
4//! is shed at the front door without spending any WASM CPU. This is the operator's native *floor*
5//! — distinct from the per-filter `host-ratelimit` capability (ADR 000026), which is a filter-driven
6//! *policy* limiter. The bucket math is shared (`plecto_host::apply_bucket`); only the state lives
7//! here, and consulting it never crosses the WASM boundary.
8//!
9//! Two keying modes (manifest `key`):
10//! - `route` — one shared bucket: a total cap on the route regardless of client.
11//! - `client-ip` — a per-client bucket keyed on the connection peer (v4 /32, v6 /64). The peer is
12//! the kernel's address, not a forgeable `X-Forwarded-For` (ADR 000018), so an attacker cannot
13//! spoof a request onto another client's bucket.
14//!
15//! CWE-770 (unbounded keys → OOM): a per-IP *map* would grow one entry per distinct source address,
16//! so a many-source or spoofed-QUIC flood could exhaust memory. We bound it BY CONSTRUCTION — a
17//! fixed-size table of buckets, the peer hashed into a slot. Memory is O(1); an attacker cannot grow
18//! it at all. The cost is hash collisions (two IPs share a slot and over-throttle each other under a
19//! flood), which is bounded collateral, never an allocation. The table hash is per-instance seeded,
20//! so an attacker cannot pre-compute which source IPs collide with a victim's slot.
21
22use std::collections::hash_map::RandomState;
23use std::hash::{BuildHasher, Hasher};
24use std::net::IpAddr;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use parking_lot::Mutex;
28use plecto_host::{Bucket, apply_bucket};
29
30use crate::manifest::{RateLimitKeyKind, RouteRateLimit};
31
32/// Slots in a per-client-IP table. A power of two so `slot = hash & (N - 1)`. At 16 bytes of bucket
33/// state per slot this is a fixed ~1 MiB per `client-ip` route, independent of traffic — the
34/// structural CWE-770 bound. Routes with a per-IP limit are operator-declared and few.
35const IP_SLOTS: usize = 1 << 16;
36
37/// Wall-clock milliseconds — the same request clock the upstream registry uses for outlier windows
38/// (ADR 000032). The token-bucket math is difference-based with `saturating_sub`, so a backward NTP
39/// step just yields zero refill until the clock catches up (never spurious tokens) and a forward
40/// step grants at most `capacity` extra. Monotonicity is not required.
41fn now_millis() -> u64 {
42 SystemTime::now()
43 .duration_since(UNIX_EPOCH)
44 .map(|d| d.as_millis() as u64)
45 .unwrap_or(0)
46}
47
48/// The fast path's view of a rate-limit consult (ADR 000033).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RateLimitDecision {
51 /// A token was available (or the route has no limiter) — forward.
52 Allow,
53 /// The bucket was empty — fail closed with 429. `retry_after_ms` is an advisory upper bound on
54 /// when a token next frees up (surfaced as `Retry-After`).
55 Limit { retry_after_ms: u64 },
56}
57
58/// A compiled native rate limiter for one route. Holds the token-bucket spec plus per-key state;
59/// shared (`Arc`) across all requests on the route within a config generation, so a reload resets
60/// the buckets — node-local and ephemeral (the floor simply re-arms full, which is safe).
61pub(crate) struct NativeRateLimit {
62 spec: Bucket,
63 state: LimiterState,
64}
65
66// Manual: `RandomState` is not `Debug`, and dumping the bucket table would lock every cell. The
67// route's `CompiledRoute`/`RouteInfo` derive `Debug`, so this keeps that cheap and lock-free.
68impl std::fmt::Debug for NativeRateLimit {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 let key = match self.state {
71 LimiterState::Route(_) => "route",
72 LimiterState::ClientIp { .. } => "client-ip",
73 };
74 f.debug_struct("NativeRateLimit")
75 .field("capacity", &self.spec.capacity)
76 .field("refill_tokens", &self.spec.refill_tokens)
77 .field("key", &key)
78 .finish()
79 }
80}
81
82enum LimiterState {
83 /// One bucket shared by the whole route.
84 Route(Mutex<(u64, u64)>),
85 /// A fixed-size table of per-IP buckets, the peer hashed into a slot (seeded per instance).
86 ClientIp {
87 slots: Box<[Mutex<(u64, u64)>]>,
88 hasher: RandomState,
89 },
90}
91
92impl NativeRateLimit {
93 /// Build a limiter from a manifest spec. `rate`/`burst` are validated non-zero by `build_active`
94 /// before this runs, so the bucket always refills and can always hold at least one token.
95 pub(crate) fn new(cfg: RouteRateLimit) -> Self {
96 let spec = Bucket {
97 capacity: cfg.burst,
98 refill_tokens: cfg.rate,
99 refill_interval_ms: 1000,
100 };
101 let state = match cfg.key {
102 RateLimitKeyKind::Route => LimiterState::Route(Mutex::new((0, 0))),
103 RateLimitKeyKind::ClientIp => LimiterState::ClientIp {
104 // Zero-initialised: a zero cell `(0, 0)` refills from epoch on first use, so a
105 // never-touched slot reads as a full bucket — no separate "first sight" sentinel.
106 slots: (0..IP_SLOTS).map(|_| Mutex::new((0, 0))).collect(),
107 hasher: RandomState::new(),
108 },
109 };
110 Self { spec, state }
111 }
112
113 /// Consult the limiter for one request (cost 1). `peer` is the connection's real remote IP.
114 pub(crate) fn check(&self, peer: IpAddr) -> RateLimitDecision {
115 self.check_at(peer, now_millis())
116 }
117
118 /// `check` against an explicit clock — the real entry point, with `now_ms` injectable for
119 /// deterministic tests (the bucket math advances purely by `now_ms`).
120 // INVARIANT: `slot_for_ip` masks its hash with `len - 1` (`IP_SLOTS` is a power of two), so the
121 // result is always `< slots.len()`.
122 #[allow(clippy::indexing_slicing)]
123 fn check_at(&self, peer: IpAddr, now_ms: u64) -> RateLimitDecision {
124 let cell = match &self.state {
125 LimiterState::Route(cell) => cell,
126 LimiterState::ClientIp { slots, hasher } => {
127 let slot = slot_for_ip(peer, hasher, slots.len());
128 debug_assert!(slot < slots.len());
129 &slots[slot]
130 }
131 };
132 let mut guard = cell.lock();
133 let (next, acq) = apply_bucket(Some(*guard), 1, self.spec, now_ms);
134 *guard = next;
135 if acq.allowed {
136 RateLimitDecision::Allow
137 } else {
138 RateLimitDecision::Limit {
139 retry_after_ms: acq.retry_after_ms,
140 }
141 }
142 }
143}
144
145/// The fixed 8-byte key a peer hashes on: v4 /32 (the four octets) or v6 /64 (the top eight). An
146/// IPv4-mapped IPv6 peer (`::ffff:a.b.c.d` on a dual-stack listener) collapses to its v4 form,
147/// matching the `X-Forwarded-For` value the fast path emits (ADR 000018) so the same client is one
148/// key. Coarsening v6 to /64 stops a single host evading its bucket by rotating addresses within the
149/// /64 it controls.
150fn ip_key_bytes(peer: IpAddr) -> [u8; 8] {
151 let peer = match peer {
152 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
153 Some(v4) => IpAddr::V4(v4),
154 None => IpAddr::V6(v6),
155 },
156 v4 => v4,
157 };
158 match peer {
159 IpAddr::V4(v4) => {
160 let o = v4.octets();
161 [o[0], o[1], o[2], o[3], 0, 0, 0, 0]
162 }
163 IpAddr::V6(v6) => {
164 let o = v6.octets();
165 [o[0], o[1], o[2], o[3], o[4], o[5], o[6], o[7]]
166 }
167 }
168}
169
170fn slot_for_ip(peer: IpAddr, hasher: &RandomState, len: usize) -> usize {
171 let mut h = hasher.build_hasher();
172 h.write(&ip_key_bytes(peer));
173 (h.finish() as usize) & (len - 1)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::net::{Ipv4Addr, Ipv6Addr};
180
181 fn ip(s: &str) -> IpAddr {
182 s.parse().unwrap()
183 }
184
185 fn route_limit(rate: u64, burst: u64, key: RateLimitKeyKind) -> NativeRateLimit {
186 NativeRateLimit::new(RouteRateLimit { rate, burst, key })
187 }
188
189 #[test]
190 fn route_bucket_starts_full_drains_then_refills() {
191 // burst = 3, rate = 2/s, route-keyed (one shared bucket). At a pinned clock the bucket
192 // starts full (the zero cell refills from epoch), so the first 3 consume, the 4th is denied.
193 let rl = route_limit(2, 3, RateLimitKeyKind::Route);
194 let peer = ip("203.0.113.7");
195 let t0 = 1_000_000;
196 for _ in 0..3 {
197 assert_eq!(rl.check_at(peer, t0), RateLimitDecision::Allow);
198 }
199 match rl.check_at(peer, t0) {
200 RateLimitDecision::Limit { retry_after_ms } => {
201 // 1 token needed, 2/s refill, 1000ms interval → one whole interval away.
202 assert_eq!(retry_after_ms, 1000);
203 }
204 RateLimitDecision::Allow => panic!("4th request over a burst of 3 must be limited"),
205 }
206 // After one refill interval, `rate` tokens are back.
207 assert_eq!(rl.check_at(peer, t0 + 1000), RateLimitDecision::Allow);
208 assert_eq!(rl.check_at(peer, t0 + 1000), RateLimitDecision::Allow);
209 assert_eq!(
210 rl.check_at(peer, t0 + 1000),
211 RateLimitDecision::Limit {
212 retry_after_ms: 1000
213 },
214 "only `rate` (2) tokens refill per interval, capped well under the 4th"
215 );
216 }
217
218 #[test]
219 fn client_ip_drains_one_peer_independently() {
220 // Same peer drains its own bucket deterministically (same slot every time).
221 let rl = route_limit(1, 2, RateLimitKeyKind::ClientIp);
222 let a = ip("198.51.100.1");
223 let t0 = 5_000_000;
224 assert_eq!(rl.check_at(a, t0), RateLimitDecision::Allow);
225 assert_eq!(rl.check_at(a, t0), RateLimitDecision::Allow);
226 assert!(matches!(
227 rl.check_at(a, t0),
228 RateLimitDecision::Limit { .. }
229 ));
230 }
231
232 #[test]
233 fn client_ip_isolates_distinct_peers() {
234 // Drain one peer's bucket, then a wide sample of other peers must still pass: only the few
235 // (≈0) colliding with the drained slot could be affected. With 256 distinct IPs over 65536
236 // slots, the expected collisions with one slot is ~0, so the generous bound is deterministic.
237 let rl = route_limit(1, 1, RateLimitKeyKind::ClientIp);
238 let t0 = 7_000_000;
239 let drained = ip("192.0.2.255");
240 assert_eq!(rl.check_at(drained, t0), RateLimitDecision::Allow);
241 assert!(
242 matches!(rl.check_at(drained, t0), RateLimitDecision::Limit { .. }),
243 "the drained peer is now limited"
244 );
245
246 let allowed = (0..=255u8)
247 .map(|n| IpAddr::V4(Ipv4Addr::new(192, 0, 2, n)))
248 .filter(|p| *p != drained)
249 .filter(|p| rl.check_at(*p, t0) == RateLimitDecision::Allow)
250 .count();
251 assert!(
252 allowed >= 250,
253 "distinct peers get independent buckets (got {allowed}/255 allowed)"
254 );
255 }
256
257 #[test]
258 fn ipv4_mapped_v6_collapses_to_v4_key() {
259 // A dual-stack listener sees an IPv4 client as `::ffff:a.b.c.d`; it must key identically to
260 // the bare v4 form so the client is one bucket, matching the emitted X-Forwarded-For.
261 let v4 = ip("198.51.100.9");
262 let mapped = IpAddr::V6(Ipv4Addr::new(198, 51, 100, 9).to_ipv6_mapped());
263 assert_eq!(ip_key_bytes(v4), ip_key_bytes(mapped));
264 }
265
266 #[test]
267 fn ipv6_key_is_the_64_prefix() {
268 // Two addresses sharing a /64 collapse to the same key; a different /64 does not.
269 let a = IpAddr::V6("2001:db8:abcd:1::1".parse::<Ipv6Addr>().unwrap());
270 let b = IpAddr::V6(
271 "2001:db8:abcd:1:ffff:ffff:ffff:ffff"
272 .parse::<Ipv6Addr>()
273 .unwrap(),
274 );
275 let other = IpAddr::V6("2001:db8:abcd:2::1".parse::<Ipv6Addr>().unwrap());
276 assert_eq!(ip_key_bytes(a), ip_key_bytes(b));
277 assert_ne!(ip_key_bytes(a), ip_key_bytes(other));
278 }
279}