#65 RateLimiter: per-key map never evicts, so source-IP rotation grows memory without bound

closedsecurityopened
details
updated
id
80bf7564-337c-424b-a06d-362c9e18373c

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:

  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:

Acceptance:

Comments (1)

all issues