Skip to main content

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}