Skip to main content

sova_rate_limit/
lib.rs

1//! Rate limiting for Sova (Express [`express-rate-limit`](https://www.npmjs.com/package/express-rate-limit)-style).
2
3mod events;
4
5pub use events::RateLimitExceeded;
6
7use sova_core::extend::{named, MwEntry};
8use sova_core::{
9    with_state, App, ClientAddr, EventBus, Plugin, RateLimitIdentity, Request, Response,
10};
11use sova_store::KvStore;
12use serde_json::Value as JsonValue;
13use std::collections::HashMap;
14use std::net::IpAddr;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
17
18/// How the rate-limit key is derived (overridden by [`RateLimit::key_fn`]).
19#[derive(Clone, Copy, Debug, Default)]
20pub enum RateLimitKey {
21    /// Client IP ([`ClientAddr`]).
22    #[default]
23    Ip,
24    /// [`RateLimitIdentity`] when set, otherwise IP.
25    Identity,
26    /// `ip` + lowercased form/json field (empty field → IP only).
27    IpAndInput {
28        field: &'static str,
29    },
30}
31
32/// Result of a rate-limit check.
33struct Outcome {
34    allowed: bool,
35    limit: u64,
36    remaining: u64,
37    /// Unix timestamp (seconds) when the window resets.
38    reset: u64,
39}
40
41type KeyFn = Arc<dyn Fn(&Request) -> String + Send + Sync>;
42type SkipFn = Arc<dyn Fn(&Request) -> bool + Send + Sync>;
43
44/// Rate limiter plugin / route middleware.
45pub struct RateLimit {
46    max: usize,
47    window: Duration,
48    max_entries: usize,
49    message: String,
50    key: RateLimitKey,
51    /// Optional prefix so presets (login/forgot) do not share buckets.
52    prefix: Option<&'static str>,
53    key_fn: Option<KeyFn>,
54    skip: Option<SkipFn>,
55    backend: Backend,
56}
57
58enum Backend {
59    LocalSliding,
60    SharedFixed(Arc<dyn KvStore>),
61}
62
63impl RateLimit {
64    /// Local process sliding window (default).
65    pub fn new(max: usize, window: Duration) -> Self {
66        Self {
67            max,
68            window,
69            max_entries: 10_000,
70            message: "Too Many Requests".into(),
71            key: RateLimitKey::Ip,
72            prefix: None,
73            key_fn: None,
74            skip: None,
75            backend: Backend::LocalSliding,
76        }
77    }
78
79    pub fn per_minute(max: usize) -> Self {
80        Self::new(max, Duration::from_secs(60))
81    }
82
83    /// Cap distinct client keys retained for local sliding (default 10_000).
84    pub fn max_entries(mut self, n: usize) -> Self {
85        self.max_entries = n.max(1);
86        self
87    }
88
89    /// 429 response body (default `"Too Many Requests"`).
90    pub fn message(mut self, msg: impl Into<String>) -> Self {
91        self.message = msg.into();
92        self
93    }
94
95    /// Built-in key strategy (default [`RateLimitKey::Ip`]).
96    pub fn key(mut self, key: RateLimitKey) -> Self {
97        self.key = key;
98        self
99    }
100
101    /// Prefix prepended to the resolved key (`login:…`, `forgot:…`).
102    pub fn prefix(mut self, prefix: &'static str) -> Self {
103        self.prefix = Some(prefix);
104        self
105    }
106
107    /// Custom rate-limit key (overrides [`Self::key`]).
108    pub fn key_fn<F>(mut self, f: F) -> Self
109    where
110        F: Fn(&Request) -> String + Send + Sync + 'static,
111    {
112        self.key_fn = Some(Arc::new(f));
113        self
114    }
115
116    /// Skip limiting when this returns true.
117    pub fn skip<F>(mut self, f: F) -> Self
118    where
119        F: Fn(&Request) -> bool + Send + Sync + 'static,
120    {
121        self.skip = Some(Arc::new(f));
122        self
123    }
124
125    /// Multi-process / shared store: fixed window via atomic `incr`.
126    pub fn fixed_window(store: Arc<dyn KvStore>, max: usize, window: Duration) -> Self {
127        Self {
128            max,
129            window,
130            max_entries: 10_000,
131            message: "Too Many Requests".into(),
132            key: RateLimitKey::Ip,
133            prefix: None,
134            key_fn: None,
135            skip: None,
136            backend: Backend::SharedFixed(store),
137        }
138    }
139
140    /// Like [`Self::fixed_window`], using an installed [`sova_store::AppStore`] (`rl` namespace).
141    pub fn shared(app: &App, max: usize, window: Duration) -> Self {
142        let store = app.try_state::<sova_store::AppStore>().unwrap_or_else(|| {
143            panic!("RateLimit::shared requires SharedStore / AppStore installed first")
144        });
145        Self::fixed_window(store.namespaced("rl"), max, window)
146    }
147
148    /// Login POST throttle: 5 / 60s, key = IP + email.
149    pub fn login() -> Self {
150        Self::per_minute(5)
151            .prefix("login")
152            .key(RateLimitKey::IpAndInput { field: "email" })
153    }
154
155    /// Forgot-password throttle: 5 / 60s, IP + email.
156    pub fn forgot() -> Self {
157        Self::per_minute(5)
158            .prefix("forgot")
159            .key(RateLimitKey::IpAndInput { field: "email" })
160    }
161
162    /// 2FA challenge throttle: 5 / 60s, IP.
163    pub fn challenge() -> Self {
164        Self::per_minute(5).prefix("2fa").key(RateLimitKey::Ip)
165    }
166
167    /// Email verification resend: 6 / 60s, IP + email.
168    pub fn resend() -> Self {
169        Self::new(6, Duration::from_secs(60))
170            .prefix("verify-resend")
171            .key(RateLimitKey::IpAndInput { field: "email" })
172    }
173
174    /// Route / mount middleware (same check as the plugin).
175    pub fn middleware(self) -> MwEntry {
176        let runtime = self.into_runtime();
177        named(
178            runtime.mw_name(),
179            with_state(runtime, |rt, mut req, next| async move {
180                if rt.skip.as_ref().is_some_and(|f| f(&req)) {
181                    return next(req).await;
182                }
183                let key =
184                    resolve_key(&mut req, rt.key_fn.as_ref(), rt.key, rt.prefix).await;
185                let out = match &rt.backend {
186                    RuntimeBackend::Sliding(w) => w.check(&key),
187                    RuntimeBackend::Fixed(w) => w.check(&key).await,
188                };
189                if !out.allowed {
190                    emit_exceeded(&req, &key, &out);
191                    return limited(rt.message.as_str(), &out);
192                }
193                let mut res = next(req).await;
194                attach_headers(&mut res, &out);
195                res
196            }),
197        )
198    }
199
200    fn into_runtime(self) -> RateLimitRuntime {
201        let backend = match self.backend {
202            Backend::LocalSliding => RuntimeBackend::Sliding(Arc::new(SlidingWindow::new(
203                self.max,
204                self.window,
205                self.max_entries,
206            ))),
207            Backend::SharedFixed(store) => RuntimeBackend::Fixed(Arc::new(FixedWindow {
208                store,
209                max: self.max as u64,
210                window: self.window,
211            })),
212        };
213        RateLimitRuntime {
214            message: self.message,
215            key: self.key,
216            prefix: self.prefix,
217            key_fn: self.key_fn,
218            skip: self.skip,
219            backend,
220            name: self
221                .prefix
222                .map(|p| format!("rate-limit:{p}"))
223                .unwrap_or_else(|| "rate-limit".into()),
224        }
225    }
226}
227
228struct RateLimitRuntime {
229    message: String,
230    key: RateLimitKey,
231    prefix: Option<&'static str>,
232    key_fn: Option<KeyFn>,
233    skip: Option<SkipFn>,
234    backend: RuntimeBackend,
235    name: String,
236}
237
238impl RateLimitRuntime {
239    fn mw_name(&self) -> String {
240        self.name.clone()
241    }
242}
243
244enum RuntimeBackend {
245    Sliding(Arc<SlidingWindow>),
246    Fixed(Arc<FixedWindow>),
247}
248
249impl Plugin for RateLimit {
250    fn id(&self) -> &'static str {
251        "rate-limit"
252    }
253
254    fn meta(&self) -> sova_core::PluginMeta {
255        sova_core::PluginMeta::new("Rate limit")
256            .description("Per-key request rate limiting")
257            .version(env!("CARGO_PKG_VERSION"))
258    }
259
260    fn install(self, app: &mut App) {
261        let runtime = self.into_runtime();
262        app.use_middleware(named(
263            runtime.mw_name(),
264            with_state(runtime, |rt, mut req, next| async move {
265                if rt.skip.as_ref().is_some_and(|f| f(&req)) {
266                    return next(req).await;
267                }
268                let key =
269                    resolve_key(&mut req, rt.key_fn.as_ref(), rt.key, rt.prefix).await;
270                let out = match &rt.backend {
271                    RuntimeBackend::Sliding(w) => w.check(&key),
272                    RuntimeBackend::Fixed(w) => w.check(&key).await,
273                };
274                if !out.allowed {
275                    emit_exceeded(&req, &key, &out);
276                    return limited(rt.message.as_str(), &out);
277                }
278                let mut res = next(req).await;
279                attach_headers(&mut res, &out);
280                res
281            }),
282        ));
283    }
284}
285
286fn emit_exceeded(req: &Request, key: &str, out: &Outcome) {
287    if let Some(bus) = req.try_state::<EventBus>() {
288        let now = unix_now();
289        bus.dispatch(RateLimitExceeded {
290            key: key.to_string(),
291            limit: out.limit,
292            retry_after: Some(out.reset.saturating_sub(now)),
293        });
294    }
295}
296
297async fn resolve_key(
298    req: &mut Request,
299    key_fn: Option<&KeyFn>,
300    strategy: RateLimitKey,
301    prefix: Option<&'static str>,
302) -> String {
303    let raw = if let Some(f) = key_fn {
304        f(req)
305    } else {
306        match strategy {
307            RateLimitKey::Ip => client_ip(req).to_string(),
308            RateLimitKey::Identity => {
309                if let Some(id) = req.get::<RateLimitIdentity>() {
310                    format!("id:{}", id.0)
311                } else {
312                    client_ip(req).to_string()
313                }
314            }
315            RateLimitKey::IpAndInput { field } => {
316                let ip = client_ip(req);
317                match peek_input_field(req, field).await {
318                    Some(v) if !v.is_empty() => format!("{ip}:{}", v.to_ascii_lowercase()),
319                    _ => ip.to_string(),
320                }
321            }
322        }
323    };
324    match prefix {
325        Some(p) => format!("{p}:{raw}"),
326        None => raw,
327    }
328}
329
330async fn peek_input_field(req: &mut Request, field: &str) -> Option<String> {
331    let ct = req
332        .header("content-type")
333        .unwrap_or("")
334        .to_ascii_lowercase();
335    if ct.contains("application/json") {
336        let Ok(v) = req.json::<JsonValue>().await else {
337            return None;
338        };
339        return v
340            .get(field)
341            .and_then(|x| x.as_str())
342            .map(|s| s.trim().to_string())
343            .filter(|s| !s.is_empty());
344    }
345    // form / multipart / urlencoded — cached on request via input()
346    if let Ok(data) = req.input().await {
347        return data
348            .get(field)
349            .map(|s| s.trim().to_string())
350            .filter(|s| !s.is_empty());
351    }
352    None
353}
354
355fn client_ip(req: &Request) -> IpAddr {
356    req.get::<ClientAddr>()
357        .map(|a| a.0.ip())
358        .unwrap_or_else(|| IpAddr::from([127, 0, 0, 1]))
359}
360
361fn limited(message: &str, out: &Outcome) -> Response {
362    let mut res = Response::text(message).status(429);
363    attach_headers(&mut res, out);
364    res
365}
366
367fn attach_headers(res: &mut Response, out: &Outcome) {
368    if let Ok(v) = out.limit.to_string().parse() {
369        res.headers_mut().insert("ratelimit-limit", v);
370    }
371    if let Ok(v) = out.remaining.to_string().parse() {
372        res.headers_mut().insert("ratelimit-remaining", v);
373    }
374    if let Ok(v) = out.reset.to_string().parse() {
375        res.headers_mut().insert("ratelimit-reset", v);
376    }
377}
378
379fn unix_now() -> u64 {
380    SystemTime::now()
381        .duration_since(UNIX_EPOCH)
382        .map(|d| d.as_secs())
383        .unwrap_or(0)
384}
385
386struct SlidingWindow {
387    inner: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
388    max: usize,
389    window: Duration,
390    max_entries: usize,
391}
392
393impl SlidingWindow {
394    fn new(max: usize, window: Duration, max_entries: usize) -> Self {
395        Self {
396            inner: Arc::new(Mutex::new(HashMap::new())),
397            max,
398            window,
399            max_entries,
400        }
401    }
402
403    fn check(&self, key: &str) -> Outcome {
404        let mut map = self.inner.lock().unwrap();
405        let now = Instant::now();
406        let reset = unix_now() + self.window.as_secs().max(1);
407
408        if let Some(entries) = map.get_mut(key) {
409            entries.retain(|t| now.duration_since(*t) < self.window);
410            if entries.is_empty() {
411                map.remove(key);
412            }
413        }
414
415        if map.len() >= self.max_entries && !map.contains_key(key) {
416            map.retain(|_, v| {
417                v.retain(|t| now.duration_since(*t) < self.window);
418                !v.is_empty()
419            });
420            while map.len() >= self.max_entries {
421                if let Some(k) = map.keys().next().cloned() {
422                    map.remove(&k);
423                } else {
424                    break;
425                }
426            }
427        }
428
429        let entries = map.entry(key.to_string()).or_default();
430        entries.retain(|t| now.duration_since(*t) < self.window);
431        let limit = self.max as u64;
432        if entries.len() >= self.max {
433            return Outcome {
434                allowed: false,
435                limit,
436                remaining: 0,
437                reset,
438            };
439        }
440        entries.push(now);
441        let remaining = limit.saturating_sub(entries.len() as u64);
442        Outcome {
443            allowed: true,
444            limit,
445            remaining,
446            reset,
447        }
448    }
449
450    #[cfg(test)]
451    fn len(&self) -> usize {
452        self.inner.lock().unwrap().len()
453    }
454
455    #[cfg(test)]
456    fn allow(&self, key: &str) -> bool {
457        self.check(key).allowed
458    }
459}
460
461struct FixedWindow {
462    store: Arc<dyn KvStore>,
463    max: u64,
464    window: Duration,
465}
466
467impl FixedWindow {
468    async fn check(&self, key: &str) -> Outcome {
469        let secs = self.window.as_secs().max(1);
470        let now = unix_now();
471        let bucket = now / secs;
472        let reset = (bucket + 1) * secs;
473        let store_key = format!("rl:{key}:{bucket}");
474        let n = self.store.incr(&store_key, 1, Some(self.window)).await;
475        let limit = self.max;
476        if n > self.max {
477            Outcome {
478                allowed: false,
479                limit,
480                remaining: 0,
481                reset,
482            }
483        } else {
484            Outcome {
485                allowed: true,
486                limit,
487                remaining: limit.saturating_sub(n),
488                reset,
489            }
490        }
491    }
492
493    #[cfg(test)]
494    async fn allow(&self, key: &str) -> bool {
495        self.check(key).await.allowed
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use sova_store::MemoryStore;
503
504    #[test]
505    fn max_entries_caps_distinct_keys() {
506        let w = SlidingWindow::new(100, Duration::from_secs(60), 3);
507        assert!(w.allow("1"));
508        assert!(w.allow("2"));
509        assert!(w.allow("3"));
510        assert_eq!(w.len(), 3);
511        assert!(w.allow("4"));
512        assert!(w.len() <= 3);
513    }
514
515    #[test]
516    fn rate_blocks_after_max() {
517        let w = SlidingWindow::new(2, Duration::from_secs(60), 10);
518        assert!(w.allow("ip"));
519        assert!(w.allow("ip"));
520        assert!(!w.allow("ip"));
521    }
522
523    #[tokio::test]
524    async fn fixed_window_incr() {
525        let store = Arc::new(MemoryStore::new());
526        let fw = FixedWindow {
527            store,
528            max: 2,
529            window: Duration::from_secs(60),
530        };
531        assert!(fw.allow("ip").await);
532        assert!(fw.allow("ip").await);
533        assert!(!fw.allow("ip").await);
534    }
535}