Skip to main content

adminx_core/
ratelimit.rs

1// adminx-core/src/ratelimit.rs
2//
3// Fixed-window throttling for the credential endpoints, keyed by account.
4//
5// Without this, `/login` accepts unbounded password guesses and `/mfa/verify`
6// unbounded TOTP guesses — and a 6-digit code is only a million combinations,
7// which is minutes of work at HTTP speed. A throttle is what makes the second
8// factor worth having.
9//
10// Two deliberate limits on what this can do:
11//
12// 1. **Per-process.** The counters live in this process's memory, so N replicas
13//    behind a load balancer allow N times the attempts. That still bounds the
14//    attack (and matches how `AuthConfig` is set up per-process today), but a
15//    multi-replica deployment wanting a hard global bound needs a shared store.
16//
17// 2. **Keyed by account, not by client.** `ReqCtx` carries no client address —
18//    and deriving one from `X-Forwarded-For` without knowing the proxy in front
19//    would be worse than nothing, since the header is attacker-settable. So this
20//    stops a *targeted* brute force against one account, but not credential
21//    stuffing spread thinly across many accounts.
22//
23// The flip side of per-account keying is that an attacker can deliberately
24// burn an admin's attempts to keep them out. That's why this throttles on a
25// short fixed window that a successful login clears, rather than latching the
26// account until someone intervenes.
27
28use lazy_static::lazy_static;
29use once_cell::sync::OnceCell;
30use std::collections::HashMap;
31use std::sync::RwLock;
32
33/// How many failures are tolerated in a window before the key is throttled.
34#[derive(Clone, Debug)]
35pub struct Limit {
36    pub max_attempts: u32,
37    pub window_secs: i64,
38}
39
40impl Limit {
41    pub const fn new(max_attempts: u32, window_secs: i64) -> Self {
42        Self {
43            max_attempts,
44            window_secs,
45        }
46    }
47}
48
49/// Password guessing: generous enough that a fat-fingered admin won't notice.
50pub const DEFAULT_LOGIN_LIMIT: Limit = Limit::new(10, 900);
51/// TOTP guessing: tighter, because the search space is only 10^6. At 5 per 15
52/// minutes, exhausting it would take roughly 5,000 years.
53pub const DEFAULT_MFA_LIMIT: Limit = Limit::new(5, 900);
54
55/// Cap on tracked keys, so a flood of distinct emails can't grow the map without
56/// bound. Expired entries are pruned first; see `record_failure_at`.
57const MAX_TRACKED_KEYS: usize = 10_000;
58
59/// Which throttles are active. Unlike `AuthConfig` this is on by default: an
60/// operator who never calls `configure` still gets protected endpoints.
61#[derive(Clone, Debug)]
62pub struct RateLimitConfig {
63    /// Failed password attempts per account. `None` allows unlimited guessing.
64    pub login: Option<Limit>,
65    /// Failed second-factor attempts per account. `None` makes a 6-digit code
66    /// brute-forceable.
67    pub mfa: Option<Limit>,
68}
69
70impl Default for RateLimitConfig {
71    fn default() -> Self {
72        Self {
73            login: Some(DEFAULT_LOGIN_LIMIT),
74            mfa: Some(DEFAULT_MFA_LIMIT),
75        }
76    }
77}
78
79static CONFIG: OnceCell<RateLimitConfig> = OnceCell::new();
80
81/// Override the default throttles. Call before serving: the first login or MFA
82/// attempt locks the defaults in, and a later call is ignored with a warning.
83pub fn configure(config: RateLimitConfig) {
84    if CONFIG.set(config).is_err() {
85        tracing::warn!("adminx rate limits already configured; ignoring reconfigure");
86    }
87}
88
89fn config() -> &'static RateLimitConfig {
90    CONFIG.get_or_init(RateLimitConfig::default)
91}
92
93/// The active password-attempt throttle, if any.
94pub fn login_limit() -> Option<&'static Limit> {
95    config().login.as_ref()
96}
97
98/// The active second-factor throttle, if any.
99pub fn mfa_limit() -> Option<&'static Limit> {
100    config().mfa.as_ref()
101}
102
103#[derive(Clone, Debug)]
104struct Window {
105    count: u32,
106    /// Wall-clock second at which this window lapses and the count resets.
107    expires_at: i64,
108}
109
110lazy_static! {
111    static ref FAILURES: RwLock<HashMap<String, Window>> = RwLock::new(HashMap::new());
112}
113
114fn now_secs() -> i64 {
115    std::time::SystemTime::now()
116        .duration_since(std::time::UNIX_EPOCH)
117        .map(|d| d.as_secs() as i64)
118        .unwrap_or(0)
119}
120
121/// True when `key` has already used up its attempts and the window hasn't
122/// lapsed. Read-only: checking never costs an attempt.
123fn is_limited_at(key: &str, limit: &Limit, now: i64) -> bool {
124    match FAILURES.read().unwrap_or_else(|e| e.into_inner()).get(key) {
125        Some(w) if w.expires_at > now => w.count >= limit.max_attempts,
126        _ => false,
127    }
128}
129
130/// Count one failure against `key`, returning the running total for the window.
131/// The window is anchored at the *first* failure and not extended by later ones,
132/// so a throttled caller is always let back in after `window_secs`.
133fn record_failure_at(key: &str, limit: &Limit, now: i64) -> u32 {
134    let mut map = FAILURES.write().unwrap_or_else(|e| e.into_inner());
135
136    if map.len() >= MAX_TRACKED_KEYS {
137        map.retain(|_, w| w.expires_at > now);
138    }
139
140    let entry = map.entry(key.to_string()).or_insert(Window {
141        count: 0,
142        expires_at: now + limit.window_secs,
143    });
144
145    // A lapsed window starts over rather than accumulating forever.
146    if entry.expires_at <= now {
147        entry.count = 0;
148        entry.expires_at = now + limit.window_secs;
149    }
150    entry.count += 1;
151    entry.count
152}
153
154/// True when `key` is currently throttled.
155pub fn is_limited(key: &str, limit: &Limit) -> bool {
156    is_limited_at(key, limit, now_secs())
157}
158
159/// Count one failed attempt against `key`.
160pub fn record_failure(key: &str, limit: &Limit) {
161    record_failure_at(key, limit, now_secs());
162}
163
164/// Forget `key`'s failures. Called on a successful authentication so a user who
165/// eventually gets it right isn't punished for the fumbles along the way.
166pub fn reset(key: &str) {
167    FAILURES.write().unwrap_or_else(|e| e.into_inner()).remove(key);
168}
169
170/// Drop all counters. Test-only: the map is process-global, so tests that assert
171/// on counts need a clean slate.
172#[doc(hidden)]
173pub fn clear_all() {
174    FAILURES.write().unwrap_or_else(|e| e.into_inner()).clear();
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::sync::{Mutex, MutexGuard};
181
182    /// Time is injected via the `_at` seam so window expiry is tested outright
183    /// rather than by sleeping.
184    const T0: i64 = 1_000_000;
185
186    lazy_static! {
187        static ref TEST_LOCK: Mutex<()> = Mutex::new(());
188    }
189
190    /// `FAILURES` is process-global, so these tests would otherwise clobber each
191    /// other's counters when cargo runs them in parallel. Take the lock, then
192    /// start from a clean map.
193    fn isolated() -> MutexGuard<'static, ()> {
194        // A test that panics mid-assert poisons the lock; the state it guards is
195        // reset on entry anyway, so recover rather than cascade the failure.
196        let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
197        clear_all();
198        guard
199    }
200
201    #[test]
202    fn throttles_only_after_the_limit_is_reached() {
203        let _g = isolated();
204        let limit = Limit::new(3, 60);
205        let key = "throttles-only-after";
206
207        assert!(!is_limited_at(key, &limit, T0), "clean key is not limited");
208        assert_eq!(record_failure_at(key, &limit, T0), 1);
209        assert_eq!(record_failure_at(key, &limit, T0), 2);
210        assert!(!is_limited_at(key, &limit, T0), "under the limit, still allowed");
211
212        assert_eq!(record_failure_at(key, &limit, T0), 3);
213        assert!(is_limited_at(key, &limit, T0), "at the limit, throttled");
214    }
215
216    #[test]
217    fn window_lapses_and_the_count_starts_over() {
218        let _g = isolated();
219        let limit = Limit::new(2, 60);
220        let key = "window-lapses";
221
222        record_failure_at(key, &limit, T0);
223        record_failure_at(key, &limit, T0);
224        assert!(is_limited_at(key, &limit, T0));
225
226        // Still inside the window.
227        assert!(is_limited_at(key, &limit, T0 + 59));
228        // Lapsed.
229        assert!(!is_limited_at(key, &limit, T0 + 61));
230        assert_eq!(
231            record_failure_at(key, &limit, T0 + 61),
232            1,
233            "a lapsed window restarts at one rather than accumulating"
234        );
235    }
236
237    #[test]
238    fn window_is_anchored_at_the_first_failure() {
239        let _g = isolated();
240        let limit = Limit::new(2, 60);
241        let key = "anchored";
242
243        record_failure_at(key, &limit, T0);
244        // A later failure inside the window must not push the expiry out, or a
245        // steady drip of attempts would extend a lockout indefinitely.
246        record_failure_at(key, &limit, T0 + 50);
247        assert!(is_limited_at(key, &limit, T0 + 50));
248        assert!(
249            !is_limited_at(key, &limit, T0 + 61),
250            "expiry stays anchored to the first failure"
251        );
252    }
253
254    #[test]
255    fn success_clears_the_count() {
256        let _g = isolated();
257        let limit = Limit::new(2, 60);
258        let key = "success-clears";
259
260        record_failure_at(key, &limit, T0);
261        record_failure_at(key, &limit, T0);
262        assert!(is_limited_at(key, &limit, T0));
263
264        reset(key);
265        assert!(!is_limited_at(key, &limit, T0), "reset lifts the throttle");
266    }
267
268    #[test]
269    fn keys_are_tracked_independently() {
270        let _g = isolated();
271        let limit = Limit::new(1, 60);
272        record_failure_at("alice", &limit, T0);
273        assert!(is_limited_at("alice", &limit, T0));
274        assert!(
275            !is_limited_at("bob", &limit, T0),
276            "one account's failures must not throttle another"
277        );
278    }
279
280    #[test]
281    fn expired_entries_are_pruned_under_pressure() {
282        let _g = isolated();
283        let limit = Limit::new(1, 60);
284
285        // Fill past the cap with entries that all lapse at T0 + 60.
286        for i in 0..MAX_TRACKED_KEYS {
287            record_failure_at(&format!("key-{i}"), &limit, T0);
288        }
289        assert_eq!(FAILURES.read().unwrap_or_else(|e| e.into_inner()).len(), MAX_TRACKED_KEYS);
290
291        // A later write past the cap sweeps the lapsed ones out.
292        record_failure_at("newcomer", &limit, T0 + 61);
293        let len = FAILURES.read().unwrap_or_else(|e| e.into_inner()).len();
294        assert!(
295            len < MAX_TRACKED_KEYS,
296            "expired entries should be pruned, still holding {len}"
297        );
298        assert!(is_limited_at("newcomer", &limit, T0 + 61));
299    }
300}