id: 80bf7564-337c-424b-a06d-362c9e18373c title: 'RateLimiter: per-key map never evicts, so source-IP rotation grows memory without bound' state: closed labels:
- security assignees: [] milestone: null created: 2026-10-27T14:54:03.114650245Z updated: 2026-10-27T15:48:01.163618793Z
RateLimiter keeps a HashMap<String, Vec<u64>> and only prunes the entry for the key being checked:
fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool {
let key = salted_ip_hash(ip, date, server_salt);
let mut map = self.buckets.lock()...;
let entry = map.entry(key).or_default();
entry.retain(|&t| now.wrapping_sub(t) < 3600); // this key only
...
}
Keys are never removed and there is no global sweep, so every distinct source IP that ever submits adds a permanent entry. Two facts make this cheap to grow:
limiter.check()is called beforecheck_quota_with()and beforestore_submission, so a key is created and a timestamp recorded even when the POST is refused (quota full → 503).- Every request costs the attacker only a connection; source addresses are free on IPv6 (a single /64 is 2^64 addresses) and trivially varied.
Result: unbounded, monotonic memory growth on a public deployment; the process never returns the memory (HashMap never shrinks). A few million connections = hundreds of MB, and it is not reclaimable without a restart.
Fix direction:
- expiry sweep: on insert, drop entries whose newest timestamp is older than the window (or
map.retainwhenmap.len()crosses a threshold) - optionally cap the number of tracked keys; when full, fall back to a single shared bucket (fail closed) rather than growing
- move
limiter.checkafter the cheap rejections if you want refused submissions not to create keys (does not fix rotation, only slows it)
Acceptance:
- entries older than the window are evicted without a request from that IP
- a test inserts N distinct keys, advances the clock past the window, asserts the map shrinks
- map size has a documented bound