---
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:

```rust
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:

1. `limiter.check()` is called **before** `check_quota_with()` and **before** `store_submission`, so a key is created and a timestamp recorded even when the POST is refused (quota full → 503).
2. 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.retain` when `map.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.check` after 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
