brain_edge/wire_proxy/rate_limit.rs
1//! Per-credential token-bucket rate limiting for the wire proxy.
2//!
3//! One bucket per sniffed credential, shared across every wire connection that
4//! authenticates with the same credential. A bucket refills continuously at
5//! `refill_per_sec` up to `capacity` (the burst ceiling); each admitted op frame
6//! spends one token. `capacity == 0` disables limiting entirely (the default), so
7//! a self-host deployment opts in explicitly.
8
9use std::collections::HashMap;
10use std::sync::Mutex;
11use std::time::Instant;
12
13/// Token-bucket parameters. Deploy-time knobs, uniform with the rest of the edge
14/// config (see [`crate::EdgeConfig`]).
15#[derive(Clone, Copy, Debug)]
16pub struct RateLimitConfig {
17 /// Bucket capacity — the maximum burst of ops admitted back-to-back. `0`
18 /// disables rate limiting (every op is admitted).
19 pub capacity: u32,
20 /// Tokens refilled per second (the sustained op rate once the burst is spent).
21 pub refill_per_sec: u32,
22}
23
24impl RateLimitConfig {
25 /// Whether limiting is off (capacity `0`), in which case [`RateLimiters::allow`]
26 /// never rejects and never allocates a bucket.
27 #[must_use]
28 pub fn disabled(&self) -> bool {
29 self.capacity == 0
30 }
31}
32
33/// A single credential's bucket: current token count and the last-refill instant.
34struct Bucket {
35 tokens: f64,
36 last: Instant,
37}
38
39/// Longest credential used verbatim as a map key.
40///
41/// The key is sniffed from the customer's AUTH frame, which the proxy does not
42/// and cannot validate — Brain does that, later. A frame payload may be up to
43/// 16 MiB, so without this a peer could make every key enormous. Anything
44/// longer is keyed by its hash instead: a credential this long is not a real
45/// one, so collisions among them are not a fairness concern.
46const MAX_KEY_BYTES: usize = 512;
47
48/// Prune fully-refilled buckets once the map is at least this large.
49///
50/// Chosen well above any plausible tenant count so ordinary operation never
51/// pays for the scan.
52const PRUNE_ABOVE: usize = 4_096;
53
54/// Hard ceiling on tracked buckets. Reached only under an attack that keeps
55/// every bucket actively spending, which pruning alone cannot reclaim.
56const MAX_BUCKETS: usize = 65_536;
57
58/// A registry of per-credential token buckets. Lookups take a short, non-async
59/// lock and never hold it across an `.await`, so a `std::sync::Mutex` is correct
60/// here.
61pub struct RateLimiters {
62 config: RateLimitConfig,
63 buckets: Mutex<HashMap<String, Bucket>>,
64 /// Prune fully-refilled buckets once the map is at least this large.
65 prune_above: usize,
66 /// Hard ceiling on tracked buckets.
67 ///
68 /// A field rather than a constant so the tests can drive the ceiling with
69 /// a handful of inserts. Exercising a 65 536-entry limit directly took 47
70 /// seconds and starved the other tests of CPU, which made a neighbouring
71 /// timing assertion fail — a test that slow is one nobody runs.
72 max_buckets: usize,
73}
74
75impl std::fmt::Debug for RateLimiters {
76 /// Reports the configuration and how many buckets are live — never the
77 /// credentials, which are the map's keys.
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 let live = self.buckets.lock().ok().map(|b| b.len());
80 f.debug_struct("RateLimiters")
81 .field("config", &self.config)
82 .field("live_buckets", &live)
83 .finish_non_exhaustive()
84 }
85}
86
87impl RateLimiters {
88 /// Build an empty registry with the given config.
89 #[must_use]
90 pub fn new(config: RateLimitConfig) -> Self {
91 Self {
92 config,
93 buckets: Mutex::new(HashMap::new()),
94 prune_above: PRUNE_ABOVE,
95 max_buckets: MAX_BUCKETS,
96 }
97 }
98
99 /// Same, with the memory bounds overridden so a test can reach them.
100 #[cfg(test)]
101 fn with_bounds(config: RateLimitConfig, prune_above: usize, max_buckets: usize) -> Self {
102 Self {
103 config,
104 buckets: Mutex::new(HashMap::new()),
105 prune_above,
106 max_buckets,
107 }
108 }
109
110 /// Admit one op for `key` (the sniffed credential), spending a token. Returns
111 /// `true` if a token was available, `false` if the bucket is empty (the op
112 /// should be rejected with a wire ERROR). Always `true` when limiting is off.
113 ///
114 /// # Panics
115 ///
116 /// If the bucket mutex is poisoned — which means another thread panicked
117 /// while holding it, and the limiter's accounting is no longer trustworthy.
118 pub fn allow(&self, key: &str) -> bool {
119 if self.config.disabled() {
120 return true;
121 }
122 let cap = f64::from(self.config.capacity);
123 let refill = f64::from(self.config.refill_per_sec);
124 let now = Instant::now();
125 let key = Self::bucket_key(key);
126
127 let mut buckets = self.buckets.lock().expect("rate-limit mutex poisoned");
128
129 // The map is keyed by an *unvalidated* credential: the proxy sniffs it
130 // from the customer's AUTH frame and forwards that frame for Brain to
131 // accept or reject. A peer whose credential Brain will refuse still
132 // reaches this line, so without a bound it can insert a bucket per
133 // connection attempt and grow the map without limit — turning the
134 // defence against request floods into a memory-exhaustion vector.
135 if buckets.len() >= self.prune_above {
136 // A bucket refilled to capacity is indistinguishable from one that
137 // does not exist: a fresh bucket starts at exactly `cap`. Dropping
138 // those is therefore lossless, not an approximation — no caller can
139 // observe the difference.
140 buckets.retain(|_, b| {
141 let refilled = now
142 .duration_since(b.last)
143 .as_secs_f64()
144 .mul_add(refill, b.tokens)
145 .min(cap);
146 refilled < cap
147 });
148 }
149 if buckets.len() >= self.max_buckets && !buckets.contains_key(&key) {
150 // Every bucket is actively spending and pruning reclaimed nothing.
151 // Shedding a new credential is the right failure here: admitting it
152 // means unbounded growth, and the alternative — evicting someone
153 // else's bucket — hands an attacker a way to reset any tenant's
154 // limit.
155 return false;
156 }
157
158 let bucket = buckets.entry(key).or_insert(Bucket {
159 tokens: cap,
160 last: now,
161 });
162 // Continuous refill since the last touch, capped at the burst ceiling.
163 let elapsed = now.duration_since(bucket.last).as_secs_f64();
164 bucket.tokens = elapsed.mul_add(refill, bucket.tokens).min(cap);
165 bucket.last = now;
166
167 if bucket.tokens >= 1.0 {
168 bucket.tokens -= 1.0;
169 true
170 } else {
171 false
172 }
173 }
174
175 /// The map key for a sniffed credential, bounded in length.
176 ///
177 /// Real credentials are far under [`MAX_KEY_BYTES`] and pass through
178 /// unchanged, so buckets stay per-credential exactly as before.
179 fn bucket_key(key: &str) -> String {
180 use std::hash::{Hash, Hasher};
181
182 if key.len() <= MAX_KEY_BYTES {
183 return key.to_string();
184 }
185 let mut h = std::collections::hash_map::DefaultHasher::new();
186 key.hash(&mut h);
187 format!("oversize-{:016x}", h.finish())
188 }
189
190 /// Longest stored key in bytes (test/introspection).
191 #[cfg(test)]
192 fn longest_key(&self) -> usize {
193 self.buckets
194 .lock()
195 .expect("rate-limit mutex poisoned")
196 .keys()
197 .map(String::len)
198 .max()
199 .unwrap_or(0)
200 }
201
202 /// Number of distinct credentials with a live bucket (test/introspection).
203 #[cfg(test)]
204 fn tracked(&self) -> usize {
205 self.buckets
206 .lock()
207 .expect("rate-limit mutex poisoned")
208 .len()
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn disabled_always_admits_and_allocates_nothing() {
218 let rl = RateLimiters::new(RateLimitConfig {
219 capacity: 0,
220 refill_per_sec: 0,
221 });
222 for _ in 0..1000 {
223 assert!(rl.allow("k"));
224 }
225 assert_eq!(rl.tracked(), 0, "disabled never builds a bucket");
226 }
227
228 #[test]
229 fn spends_the_burst_then_rejects() {
230 // Capacity 3, no refill: exactly three ops admitted, the fourth rejected.
231 let rl = RateLimiters::new(RateLimitConfig {
232 capacity: 3,
233 refill_per_sec: 0,
234 });
235 assert!(rl.allow("k"));
236 assert!(rl.allow("k"));
237 assert!(rl.allow("k"));
238 assert!(!rl.allow("k"), "burst is spent");
239 }
240
241 #[test]
242 fn buckets_are_per_credential() {
243 let rl = RateLimiters::new(RateLimitConfig {
244 capacity: 1,
245 refill_per_sec: 0,
246 });
247 assert!(rl.allow("a"));
248 assert!(!rl.allow("a"), "a's single token is spent");
249 assert!(rl.allow("b"), "b has its own bucket");
250 assert_eq!(rl.tracked(), 2);
251 }
252
253 // -- memory bounds ----------------------------------------------------
254 //
255 // The bucket key is an UNVALIDATED credential, sniffed from the customer's
256 // AUTH frame. Brain decides whether it is real; the proxy inserts a bucket
257 // regardless, and the entry outlives the connection. Without the bounds
258 // below, enabling rate limiting — the defence against request floods —
259 // opens a memory-exhaustion vector against a peer that never authenticates.
260
261 #[test]
262 fn an_oversize_credential_does_not_become_an_oversize_key() {
263 // A frame payload may be 16 MiB, so an unvalidated credential can be
264 // too. Storing it verbatim as a map key is what makes that expensive.
265 let rl = RateLimiters::new(RateLimitConfig {
266 capacity: 1,
267 refill_per_sec: 0,
268 });
269 let huge = "z".repeat(1_000_000);
270 assert!(rl.allow(&huge));
271 assert_eq!(rl.tracked(), 1);
272 assert!(
273 rl.longest_key() < MAX_KEY_BYTES + 64,
274 "an oversize credential must not be stored verbatim"
275 );
276 // Still a real bucket: the second op is refused, so bounding the key
277 // did not disable limiting for this credential.
278 assert!(!rl.allow(&huge), "the bounded key still keys a real bucket");
279 }
280
281 #[test]
282 fn distinct_credentials_cannot_grow_the_map_without_bound() {
283 // Each attacker bucket is spent once and then refills to full, at which
284 // point it carries no information and is reclaimable. Bounds are small
285 // here so the test reaches them in milliseconds.
286 let rl = RateLimiters::with_bounds(
287 RateLimitConfig {
288 capacity: 1,
289 refill_per_sec: 1_000_000,
290 },
291 16,
292 1_024,
293 );
294 for i in 0..2_000 {
295 assert!(rl.allow(&format!("attacker-{i}")));
296 }
297 assert!(
298 rl.tracked() <= 16,
299 "map grew to {} entries; pruning is not reclaiming",
300 rl.tracked()
301 );
302 }
303
304 #[test]
305 fn pruning_a_full_bucket_is_lossless() {
306 // The eviction rule is sound only because a bucket refilled to capacity
307 // is indistinguishable from one that does not exist. Assert exactly
308 // that: spend the whole burst, wait for a full refill, and confirm the
309 // burst is available again — whether or not the entry was reclaimed.
310 //
311 // 100 tokens/s means one token per 10ms, so the spend loop below cannot
312 // accidentally refill mid-flight the way a 1_000_000/s rate would.
313 let rl = RateLimiters::with_bounds(
314 RateLimitConfig {
315 capacity: 2,
316 refill_per_sec: 100,
317 },
318 1,
319 1_024,
320 );
321 assert!(rl.allow("k"));
322 assert!(rl.allow("k"));
323 std::thread::sleep(std::time::Duration::from_millis(50));
324 assert!(rl.allow("k"), "first token back after a full refill");
325 assert!(
326 rl.allow("k"),
327 "and the second — the whole burst, as if fresh"
328 );
329 }
330
331 #[test]
332 fn a_spending_flood_is_shed_rather_than_grown() {
333 // No refill, so no bucket ever becomes prunable — the case pruning
334 // cannot handle. The hard ceiling is what bounds it.
335 let rl = RateLimiters::with_bounds(
336 RateLimitConfig {
337 capacity: 4,
338 refill_per_sec: 0,
339 },
340 1_000_000, // pruning effectively off; isolate the ceiling
341 32,
342 );
343 for i in 0..500 {
344 rl.allow(&format!("attacker-{i}"));
345 }
346 assert_eq!(rl.tracked(), 32, "the ceiling is what stops the growth");
347 }
348
349 #[test]
350 fn refills_over_time() {
351 // Capacity 1 at 100 tokens/s: one token per 10ms.
352 //
353 // This used to use 1000/s, so the refusal below only held if the two
354 // calls landed within a millisecond of each other. Under a loaded test
355 // runner they did not, and it failed intermittently — a flake that
356 // looks like a rate-limiter bug. 10ms is far longer than two calls can
357 // take, and 50ms is comfortably more than one refill.
358 let rl = RateLimiters::new(RateLimitConfig {
359 capacity: 1,
360 refill_per_sec: 100,
361 });
362 assert!(rl.allow("k"));
363 assert!(!rl.allow("k"), "token spent, and 10ms have not passed");
364 std::thread::sleep(std::time::Duration::from_millis(50));
365 assert!(rl.allow("k"), "token refilled after the sleep");
366 }
367}