1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
//! Rate-limiter por IP para el servidor OPRF online.
//!
//! Endurece dos problemas del contador ingenuo (hallazgo O3 de la auditoría):
//! - **Crecimiento de memoria sin límite**: el mapa expira entradas viejas y
//! tiene un tope de IPs rastreadas; al alcanzarlo, deja de admitir IPs
//! nuevas (fail-closed en memoria) en lugar de crecer indefinidamente.
//! - Sigue siendo por-IP: NO frena una botnet distribuida (eso es trabajo de
//! una capa anti-DDoS/proxy por delante); acota el guessing de una fuente.
//!
//! Es `std`-only, igual que el resto del transporte OPRF.
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
/// Contador de consultas por IP con ventana deslizante y expiración.
pub struct RateLimiter {
hits: HashMap<IpAddr, (u32, Instant)>,
max_per_window: u32,
window: Duration,
max_tracked: usize,
}
impl RateLimiter {
/// - `max_per_window`: consultas permitidas por IP dentro de `window`.
/// - `window`: duración de la ventana deslizante.
/// - `max_tracked`: tope de IPs distintas en memoria (acota el consumo).
pub fn new(max_per_window: u32, window: Duration, max_tracked: usize) -> Self {
Self {
hits: HashMap::new(),
max_per_window,
window,
max_tracked: max_tracked.max(1),
}
}
/// `true` si la IP puede consultar ahora; actualiza el contador.
pub fn allow(&mut self, ip: IpAddr) -> bool {
self.allow_at(ip, Instant::now())
}
/// Núcleo con tiempo inyectable (para tests deterministas).
fn allow_at(&mut self, ip: IpAddr, now: Instant) -> bool {
// Al alcanzar el tope de memoria, purga las entradas ya expiradas.
if self.hits.len() >= self.max_tracked {
let window = self.window;
self.hits
.retain(|_, (_, seen)| now.duration_since(*seen) <= window);
// Si tras purgar sigue lleno, rechaza IPs NUEVAS (no dejes crecer el
// mapa). Las IPs ya rastreadas siguen atendiéndose con su cuota.
if self.hits.len() >= self.max_tracked && !self.hits.contains_key(&ip) {
return false;
}
}
let entry = self.hits.entry(ip).or_insert((0, now));
if now.duration_since(entry.1) > self.window {
*entry = (0, now); // ventana expirada: reinicia
}
if entry.0 >= self.max_per_window {
return false;
}
entry.0 += 1;
true
}
/// Nº de IPs actualmente rastreadas (para observabilidad/tests).
pub fn tracked(&self) -> usize {
self.hits.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(n: u8) -> IpAddr {
IpAddr::from([10, 0, 0, n])
}
#[test]
fn allows_up_to_the_limit_then_denies() {
let mut rl = RateLimiter::new(3, Duration::from_secs(60), 1000);
let t = Instant::now();
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(1), t));
assert!(!rl.allow_at(ip(1), t), "la 4ª en la ventana se deniega");
}
#[test]
fn window_resets_after_expiry() {
let mut rl = RateLimiter::new(1, Duration::from_secs(60), 1000);
let t0 = Instant::now();
assert!(rl.allow_at(ip(1), t0));
assert!(!rl.allow_at(ip(1), t0), "consumida la cuota");
// Pasada la ventana, la cuota se reinicia.
let t1 = t0 + Duration::from_secs(120);
assert!(rl.allow_at(ip(1), t1));
}
#[test]
fn independent_ips_have_independent_quotas() {
let mut rl = RateLimiter::new(1, Duration::from_secs(60), 1000);
let t = Instant::now();
assert!(rl.allow_at(ip(1), t));
assert!(rl.allow_at(ip(2), t), "otra IP tiene su propia cuota");
}
#[test]
fn caps_tracked_ips_and_denies_new_ones_when_full() {
// Tope de 4 IPs. Cinco IPs distintas dentro de la ventana: la 5ª se
// rechaza en vez de hacer crecer el mapa (fail-closed en memoria).
let mut rl = RateLimiter::new(10, Duration::from_secs(60), 4);
let t = Instant::now();
for n in 0..4 {
assert!(rl.allow_at(ip(n), t));
}
assert_eq!(rl.tracked(), 4);
assert!(!rl.allow_at(ip(99), t), "IP nueva con el mapa lleno: denegada");
assert_eq!(rl.tracked(), 4, "el mapa no crece más allá del tope");
}
#[test]
fn expired_entries_are_evicted_to_make_room() {
// Con el mapa lleno de entradas EXPIRADAS, una IP nueva entra tras purga.
let mut rl = RateLimiter::new(10, Duration::from_secs(60), 4);
let t0 = Instant::now();
for n in 0..4 {
assert!(rl.allow_at(ip(n), t0));
}
// Mucho después: las 4 están expiradas; la nueva las purga y entra.
let t1 = t0 + Duration::from_secs(600);
assert!(rl.allow_at(ip(99), t1));
assert!(rl.tracked() <= 4);
}
}