1mod 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#[derive(Clone, Copy, Debug, Default)]
20pub enum RateLimitKey {
21 #[default]
23 Ip,
24 Identity,
26 IpAndInput {
28 field: &'static str,
29 },
30}
31
32struct Outcome {
34 allowed: bool,
35 limit: u64,
36 remaining: u64,
37 reset: u64,
39}
40
41type KeyFn = Arc<dyn Fn(&Request) -> String + Send + Sync>;
42type SkipFn = Arc<dyn Fn(&Request) -> bool + Send + Sync>;
43
44pub struct RateLimit {
46 max: usize,
47 window: Duration,
48 max_entries: usize,
49 message: String,
50 key: RateLimitKey,
51 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 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 pub fn max_entries(mut self, n: usize) -> Self {
85 self.max_entries = n.max(1);
86 self
87 }
88
89 pub fn message(mut self, msg: impl Into<String>) -> Self {
91 self.message = msg.into();
92 self
93 }
94
95 pub fn key(mut self, key: RateLimitKey) -> Self {
97 self.key = key;
98 self
99 }
100
101 pub fn prefix(mut self, prefix: &'static str) -> Self {
103 self.prefix = Some(prefix);
104 self
105 }
106
107 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 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 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 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 pub fn login() -> Self {
150 Self::per_minute(5)
151 .prefix("login")
152 .key(RateLimitKey::IpAndInput { field: "email" })
153 }
154
155 pub fn forgot() -> Self {
157 Self::per_minute(5)
158 .prefix("forgot")
159 .key(RateLimitKey::IpAndInput { field: "email" })
160 }
161
162 pub fn challenge() -> Self {
164 Self::per_minute(5).prefix("2fa").key(RateLimitKey::Ip)
165 }
166
167 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 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 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}