Skip to main content

guard_core_engine/
ip_ban.rs

1//! The dynamic IP ban store and the auto-ban engine: `ban_ip` with a
2//! duration, expiry-honoring `is_banned`, and the per-IP violation counters
3//! with the threshold resolution both are fed by.
4//!
5//! This is the Rust family's in-memory port of the reference engine's
6//! dynamic ban layer (`guard_core/handlers/ipban_handler.py` and the
7//! threshold helper `core/checks/helpers.py:_resolve_and_apply_threshold_ban`,
8//! via the Go port's `ipban.go`). Redis is out of scope: the store is
9//! process-local, exactly what both references fall back to when Redis is
10//! off, and the follow-up that adds Redis adopts the full distributed
11//! semantics (shared bans, expiry refresh, legacy key migration).
12//!
13//! ## Bans
14//!
15//! [`IpBanManager::ban_ip`] records an expiry (`now + duration`) and
16//! [`IpBanManager::is_banned`] honors it: a lookup past the expiry returns
17//! `false` and drops the entry. Mirroring the references' local store, an
18//! entry lives at most
19//! [`LOCAL_CACHE_TTL_CAP_SECONDS`] (3600) seconds - the reference clamps
20//! longer durations to the local cap when Redis is not configured
21//! (`clampToLocalCap`, cause `not configured`) and the Python `TTLCache`
22//! caps every local entry at the same TTL - so `ban_ip` with a longer
23//! duration records the clamped expiry. The default `auto_ban_duration`
24//! (3600) sits exactly at the cap, so default autobans are unaffected.
25//!
26//! The store is an LRU capped at [`LOCAL_CACHE_MAX_SIZE`] (10 000) entries
27//! (`localCacheMaxSize` / the reference `maxsize=10000`): inserting into a
28//! full store silently evicts the least recently banned entry, the same
29//! silent-overflow behavior the references log a warning for.
30//!
31//! ## Self-ban refusal
32//!
33//! `ban_ip` refuses (`Ok(false)`, no entry recorded) when the target
34//! overlaps the loopback space (`127.0.0.0/8`, `::1/128`) or a configured
35//! trusted proxy - the references' self-DoS guard, so a deployment can never
36//! ban its own ingress and go dark on itself.
37//!
38//! ## The auto-ban engine
39//!
40//! Detection violations are counted per IP per category in
41//! [`ViolationCounters`] (the reference's `suspicious_request_counts`). One
42//! call, [`IpBanManager::register_violations`], records the categories and
43//! applies the reference threshold resolution
44//! (`_resolve_and_apply_threshold_ban`, same shape as the TypeScript port's
45//! `resolveThresholdBan`):
46//!
47//! ```text
48//! banning disabled:                          no ban
49//! for each category in the recorded order:
50//!   threat_ban_config[category] exists and
51//!   count[category] >= its threshold:        ban (entry duration, reason:category)
52//! total of all counted categories >=
53//! auto_ban_threshold:                        ban (auto_ban_duration, reason)
54//! otherwise:                                 no ban
55//! ```
56//!
57//! The per-category entries win first (in the order the caller lists the
58//! categories), the flat threshold is the fallback measured against the
59//! total. The rate-limit stage feeds the same engine with the pseudo-category
60//! `rate_limit` when `enable_rate_limit_auto_ban` is on, so
61//! `threat_ban_config["rate_limit"]` overrides and the flat threshold backs
62//! it up, exactly as the reference pipeline does.
63//!
64//! # Example
65//!
66//! ```
67//! use std::net::IpAddr;
68//! use std::str::FromStr;
69//!
70//! use guard_core_engine::ip_ban::{IpBanConfig, IpBanManager};
71//!
72//! let manager = IpBanManager::new();
73//! let attacker = IpAddr::from_str("192.0.2.9").unwrap();
74//!
75//! assert!(manager.ban_ip(attacker, 60, "threshold_exceeded").unwrap());
76//! assert!(manager.is_banned(attacker));
77//!
78//! // Loopback is refused: banning it would self-DoS the deployment.
79//! let loopback = IpAddr::from_str("127.0.0.1").unwrap();
80//! assert!(!manager.ban_ip(loopback, 60, "mistake").unwrap());
81//! assert!(!manager.is_banned(loopback));
82//!
83//! manager.unban(attacker);
84//! assert!(!manager.is_banned(attacker));
85//! ```
86
87use std::collections::{HashMap, HashSet};
88use std::net::IpAddr;
89use std::num::NonZeroUsize;
90use std::sync::{Arc, Mutex};
91
92use lru::LruCache;
93
94use crate::ip_gate::{IpGateError, IpNet, canonical, parse_network_entry};
95
96/// The flat auto-ban fallback threshold (`auto_ban_threshold`, reference
97/// default).
98pub const DEFAULT_AUTO_BAN_THRESHOLD: u32 = 10;
99/// The flat auto-ban fallback duration in seconds (`auto_ban_duration`,
100/// reference default: one hour).
101pub const DEFAULT_AUTO_BAN_DURATION: u64 = 3600;
102/// The local store's TTL cap in seconds (`LOCAL_CACHE_TTL_CAP_SECONDS` in
103/// both references): no in-memory ban outlives it.
104pub const LOCAL_CACHE_TTL_CAP_SECONDS: u64 = 3600;
105/// The local ban store's entry cap (`localCacheMaxSize` /
106/// the reference `maxsize=10000`).
107pub const LOCAL_CACHE_MAX_SIZE: usize = 10_000;
108/// The violation counter store's IP cap (`_MAX_TRACKED_SUSPICIOUS_IPS`).
109pub const MAX_TRACKED_SUSPICIOUS_IPS: usize = 10_000;
110
111/// The pseudo-category the rate-limit stage feeds the auto-ban engine under
112/// (`enable_rate_limit_auto_ban`).
113pub const RATE_LIMIT_CATEGORY: &str = "rate_limit";
114
115/// One `threat_ban_config` entry: the violation count per category that
116/// triggers a ban, and how long the ban lasts.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct ThreatBanEntry {
119    /// Violations of this category after which the IP is banned.
120    pub threshold: u32,
121    /// Ban length in seconds.
122    pub duration: u64,
123}
124
125/// The IP-banning knobs (the reference `enable_ip_banning` /
126/// `auto_ban_threshold` / `auto_ban_duration` / `threat_ban_config` group).
127///
128/// The defaults are the reference `SecurityConfig` defaults: banning on
129/// (`enable_ip_banning = true`, `guard_core/_security_config_fields.py`)
130/// with the reference threshold/duration pair.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct IpBanConfig {
133    /// `enable_ip_banning`. `false` makes every ban resolution a no-op;
134    /// violations still count (the reference counts regardless so enabling
135    /// banning later starts from observed history).
136    pub enable_ip_banning: bool,
137    /// `auto_ban_threshold`: the flat fallback, measured against the total
138    /// of all counted categories.
139    pub auto_ban_threshold: u32,
140    /// `auto_ban_duration`: the flat fallback ban length (seconds).
141    pub auto_ban_duration: u64,
142    /// `threat_ban_config`: per-category threshold/duration overrides,
143    /// consulted before the flat pair.
144    pub threat_ban_config: HashMap<String, ThreatBanEntry>,
145}
146
147impl Default for IpBanConfig {
148    fn default() -> Self {
149        Self {
150            enable_ip_banning: true,
151            auto_ban_threshold: DEFAULT_AUTO_BAN_THRESHOLD,
152            auto_ban_duration: DEFAULT_AUTO_BAN_DURATION,
153            threat_ban_config: HashMap::new(),
154        }
155    }
156}
157
158/// An invalid [`IpBanConfig`]: the config error [`IpBanConfig::new`] fails
159/// closed with.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum IpBanConfigError {
162    /// A threshold or duration that must be at least 1 is not.
163    NonPositive {
164        /// The rejected field name.
165        field: &'static str,
166    },
167    /// A `threat_ban_config` key that is neither a detection category nor
168    /// the `rate_limit` pseudo-category (the reference and the TypeScript
169    /// port both reject unknown categories).
170    UnknownCategory {
171        /// The rejected key.
172        category: String,
173    },
174}
175
176impl core::fmt::Display for IpBanConfigError {
177    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
178        match self {
179            Self::NonPositive { field } => write!(f, "invalid {field}: must be at least 1"),
180            Self::UnknownCategory { category } => write!(
181                f,
182                "unknown threat category '{category}': expected a detection category or 'rate_limit'"
183            ),
184        }
185    }
186}
187
188impl std::error::Error for IpBanConfigError {}
189
190/// The detection categories a `threat_ban_config` key may name: every
191/// pattern-table category plus the `rate_limit` pseudo-category (the
192/// reference's `ALL_DETECTION_CATEGORIES | {'rate_limit'}`).
193#[must_use]
194pub fn valid_threat_categories() -> HashSet<&'static str> {
195    let mut categories: HashSet<&'static str> = crate::patterns::table::PATTERN_DEFINITIONS
196        .iter()
197        .map(|entry| entry.category)
198        .collect();
199    categories.insert(RATE_LIMIT_CATEGORY);
200    categories
201}
202
203impl IpBanConfig {
204    /// Build and validate a config, failing closed on a non-positive
205    /// threshold/duration or an unknown `threat_ban_config` category.
206    ///
207    /// # Errors
208    ///
209    /// [`IpBanConfigError::NonPositive`] for a zero `auto_ban_threshold` or
210    /// `auto_ban_duration` (the reference rejects both with `ge=1`),
211    /// [`IpBanConfigError::UnknownCategory`] for a key outside
212    /// [`valid_threat_categories`].
213    pub fn new<I, K, V>(
214        enable_ip_banning: bool,
215        auto_ban_threshold: u32,
216        auto_ban_duration: u64,
217        threat_ban_config: I,
218    ) -> Result<Self, IpBanConfigError>
219    where
220        I: IntoIterator<Item = (K, V)>,
221        K: AsRef<str>,
222        V: Into<ThreatBanEntry>,
223    {
224        let config = Self {
225            enable_ip_banning,
226            auto_ban_threshold,
227            auto_ban_duration,
228            threat_ban_config: threat_ban_config
229                .into_iter()
230                .map(|(key, entry)| (key.as_ref().to_owned(), entry.into()))
231                .collect(),
232        };
233        config.validate()?;
234        Ok(config)
235    }
236
237    /// Validate this config. Struct-literal builders can run the same
238    /// checks the constructor applies.
239    ///
240    /// # Errors
241    ///
242    /// See [`IpBanConfig::new`].
243    pub fn validate(&self) -> Result<(), IpBanConfigError> {
244        if self.auto_ban_threshold == 0 {
245            return Err(IpBanConfigError::NonPositive {
246                field: "auto_ban_threshold",
247            });
248        }
249        if self.auto_ban_duration == 0 {
250            return Err(IpBanConfigError::NonPositive {
251                field: "auto_ban_duration",
252            });
253        }
254        let valid = valid_threat_categories();
255        for category in self.threat_ban_config.keys() {
256            if !valid.contains(category.as_str()) {
257                return Err(IpBanConfigError::UnknownCategory {
258                    category: category.to_owned(),
259                });
260            }
261        }
262        Ok(())
263    }
264}
265
266/// Why [`IpBanManager::ban_ip`] refused a ban.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum BanError {
269    /// A ban duration must be at least one second (the reference raises;
270    /// the Go port answers with a 400-shaped error).
271    NonPositiveDuration,
272}
273
274impl core::fmt::Display for BanError {
275    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
276        match self {
277            Self::NonPositiveDuration => write!(f, "ban duration must be positive"),
278        }
279    }
280}
281
282impl std::error::Error for BanError {}
283
284/// One recorded ban: when it expires and why it was issued. The reason is
285/// metadata for observability (the references persist it on the Redis
286/// side); the in-memory store only acts on the expiry.
287#[derive(Debug, Clone, PartialEq)]
288pub struct BanRecord {
289    /// Unix seconds after which the ban no longer applies.
290    pub expiry: f64,
291    /// The reason `ban_ip` was called with.
292    pub reason: String,
293}
294
295/// The monotonic-ish wall clock, in seconds since the Unix epoch.
296/// Injectable so expiry behavior is testable without sleeping.
297pub type Clock = Arc<dyn Fn() -> f64 + Send + Sync>;
298
299fn system_clock() -> f64 {
300    std::time::SystemTime::now()
301        .duration_since(std::time::UNIX_EPOCH)
302        .map_or(0.0, |d| d.as_secs_f64())
303}
304
305const LOOPBACK_NETWORKS: [&str; 2] = ["127.0.0.0/8", "::1/128"];
306
307/// A fresh shared ban store (the store lives behind an `Arc` so clones of
308/// the manager share it, the references' singleton semantics).
309fn new_ban_store() -> Arc<Mutex<LruCache<IpAddr, BanRecord>>> {
310    Arc::new(Mutex::new(LruCache::new(
311        NonZeroUsize::new(LOCAL_CACHE_MAX_SIZE).expect("constant above zero"),
312    )))
313}
314
315/// The dynamic IP ban store over one shared in-memory map.
316///
317/// Build it once at startup and share the handle across requests; the
318/// internal store is a mutex-guarded LRU, safe for concurrent services.
319/// Trusted proxies are configured at construction
320/// ([`IpBanManager::with_trusted_proxies`]) because they shape the
321/// self-ban refusal, not per-request behavior.
322pub struct IpBanManager {
323    bans: Arc<Mutex<LruCache<IpAddr, BanRecord>>>,
324    trusted_proxies: Vec<IpNet>,
325    clock: Clock,
326    ban_store: Option<Arc<dyn crate::distributed::BanStore>>,
327    redis_prefix: String,
328}
329
330impl Clone for IpBanManager {
331    /// A clone shares the ban store and the clock, exactly the references'
332    /// singleton semantics: bans recorded through any handle are visible to
333    /// every handle. Trusted proxies are copied (they shape the self-DoS
334    /// refusal only).
335    fn clone(&self) -> Self {
336        Self {
337            bans: Arc::clone(&self.bans),
338            trusted_proxies: self.trusted_proxies.clone(),
339            clock: Arc::clone(&self.clock),
340            ban_store: self.ban_store.clone(),
341            redis_prefix: self.redis_prefix.clone(),
342        }
343    }
344}
345
346impl core::fmt::Debug for IpBanManager {
347    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
348        f.debug_struct("IpBanManager")
349            .field("trusted_proxies", &self.trusted_proxies.len())
350            .finish_non_exhaustive()
351    }
352}
353
354impl Default for IpBanManager {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360impl IpBanManager {
361    /// Build a ban store with no trusted proxies (loopback refusal only).
362    #[must_use]
363    pub fn new() -> Self {
364        Self {
365            bans: new_ban_store(),
366            ban_store: None,
367            redis_prefix: String::from("guard_core:"),
368            trusted_proxies: Vec::new(),
369            clock: Arc::new(system_clock),
370        }
371    }
372
373    /// Build a ban store whose self-ban refusal also covers the given
374    /// trusted proxy networks (a bare IP or a CIDR range per entry, same
375    /// matching semantics as the IP gate's lists).
376    ///
377    /// # Errors
378    ///
379    /// [`IpGateError`] naming the offending entry, exactly like
380    /// `IpGateConfig::new` fails closed.
381    pub fn with_trusted_proxies<I>(entries: I) -> Result<Self, IpGateError>
382    where
383        I: IntoIterator,
384        I::Item: AsRef<str>,
385    {
386        Self::with_trusted_proxies_and_clock(entries, Arc::new(system_clock))
387    }
388
389    /// Build a ban store whose self-ban refusal also covers the given
390    /// trusted-proxy networks and whose wall clock is injected: the combined
391    /// seam [`with_trusted_proxies`](Self::with_trusted_proxies) and
392    /// [`with_clock`](Self::with_clock) cover separately. Adapters that run
393    /// deterministic expiry tests under a production-shaped proxy list use
394    /// this one.
395    ///
396    /// # Errors
397    ///
398    /// [`IpGateError`] naming the offending entry, exactly like
399    /// `IpBanManager::with_trusted_proxies` fails closed.
400    pub fn with_trusted_proxies_and_clock<I>(entries: I, clock: Clock) -> Result<Self, IpGateError>
401    where
402        I: IntoIterator,
403        I::Item: AsRef<str>,
404    {
405        let mut trusted_proxies = Vec::new();
406        for entry in entries {
407            let entry = entry.as_ref();
408            match parse_network_entry(entry) {
409                Some(network) => trusted_proxies.push(network),
410                None => {
411                    return Err(IpGateError {
412                        list: "trusted_proxies",
413                        entry: entry.to_owned(),
414                    });
415                }
416            }
417        }
418        Ok(Self {
419            bans: new_ban_store(),
420            ban_store: None,
421            redis_prefix: String::from("guard_core:"),
422            trusted_proxies,
423            clock,
424        })
425    }
426
427    /// Swap the wall clock. Test seam: production builds use the system
428    /// clock; deterministic expiry coverage injects a fake.
429    /// Run the ban engine over a distributed store (the reference
430    /// `redis_handler` seam): ban writes go to
431    /// `{prefix}banned_ips:{ip}` with the ban TTL (a failed write
432    /// degrades to the local store, the reference `except` branch), and
433    /// lookups fall through to the store on a local miss, caching the
434    /// answer (the reference `_check_redis_exact`). A failing lookup
435    /// reads as unbanned locally: the reference propagates the backend
436    /// error into the pipeline's fail-secure handling, which this sync
437    /// `bool` seam cannot express; the deviation is recorded in the
438    /// module docs.
439    #[must_use]
440    pub fn with_distributed_store(
441        mut self,
442        store: Arc<dyn crate::distributed::BanStore>,
443        redis_prefix: &str,
444    ) -> Self {
445        self.ban_store = Some(store);
446        redis_prefix.clone_into(&mut self.redis_prefix);
447        self
448    }
449
450    pub fn with_clock(clock: Clock) -> Self {
451        Self {
452            bans: new_ban_store(),
453            ban_store: None,
454            redis_prefix: String::from("guard_core:"),
455            trusted_proxies: Vec::new(),
456            clock,
457        }
458    }
459
460    /// Ban `ip` for `duration` seconds, recording `reason`.
461    ///
462    /// Returns `Ok(true)` when the ban was recorded, `Ok(false)` when it
463    /// was refused by the self-DoS guard (loopback or a trusted proxy:
464    /// banning those would take the deployment's own view of client IPs
465    /// offline), and [`BanError::NonPositiveDuration`] for a zero duration.
466    /// Re-banning a live IP overwrites the record: the new expiry and
467    /// reason replace the old ones.
468    ///
469    /// Durations beyond [`LOCAL_CACHE_TTL_CAP_SECONDS`] are clamped to it:
470    /// this is the in-memory store, and the references cap their local
471    /// store the same way (only the Redis backend honors longer bans).
472    ///
473    /// # Errors
474    ///
475    /// [`BanError::NonPositiveDuration`] when `duration` is zero.
476    pub fn ban_ip(&self, ip: IpAddr, duration: u64, reason: &str) -> Result<bool, BanError> {
477        if duration == 0 {
478            return Err(BanError::NonPositiveDuration);
479        }
480        let ip = canonical(ip);
481        if self.self_ban_refusal(&ip) {
482            return Ok(false);
483        }
484        let clamped = duration.min(LOCAL_CACHE_TTL_CAP_SECONDS);
485        #[allow(clippy::cast_precision_loss)]
486        let expiry = (self.clock)() + clamped as f64;
487        self.bans.lock().expect("ban store").put(
488            ip,
489            BanRecord {
490                expiry,
491                reason: reason.to_owned(),
492            },
493        );
494        if let Some(store) = &self.ban_store {
495            // The distributed store honors the unclamped TTL (only the
496            // local cache is capped, exactly the reference). A failed
497            // write degrades to the local store above.
498            #[allow(clippy::cast_precision_loss)]
499            let distributed_expiry = (self.clock)() + duration as f64;
500            let _ = store.set_ban(
501                &crate::distributed::ban_key(&self.redis_prefix, ip),
502                distributed_expiry,
503                duration,
504            );
505        }
506        Ok(true)
507    }
508
509    /// Whether `ip` currently carries a live ban. An entry past its expiry
510    /// reads as unbanned and is dropped from the store.
511    #[must_use]
512    pub fn is_banned(&self, ip: IpAddr) -> bool {
513        let ip = canonical(ip);
514        let now = (self.clock)();
515        {
516            let mut bans = self.bans.lock().expect("ban store");
517            match bans.get(&ip) {
518                Some(record) if now <= record.expiry => return true,
519                Some(_) => {
520                    bans.pop(&ip);
521                }
522                None => {}
523            }
524        }
525        self.check_distributed_ban(ip, now)
526    }
527
528    /// The distributed lookup on a local miss (the reference
529    /// `_check_redis_exact`): a stored expiry at or after `now` counts as
530    /// banned and is cached locally, a stale one is deleted, a backend
531    /// error reads as unbanned.
532    fn check_distributed_ban(&self, ip: IpAddr, now: f64) -> bool {
533        let Some(store) = &self.ban_store else {
534            return false;
535        };
536        match store.get_ban(&crate::distributed::ban_key(&self.redis_prefix, ip)) {
537            Ok(Some(expiry)) if now <= expiry => {
538                self.bans.lock().expect("ban store").put(
539                    ip,
540                    BanRecord {
541                        expiry,
542                        reason: String::from("distributed"),
543                    },
544                );
545                true
546            }
547            Ok(Some(_)) => {
548                let _ = store.delete_ban(&crate::distributed::ban_key(&self.redis_prefix, ip));
549                false
550            }
551            Ok(None) | Err(_) => false,
552        }
553    }
554
555    /// The recorded ban for `ip`, if one is still live (test and
556    /// observability seam: the reason a ban was issued).
557    #[must_use]
558    pub fn ban_record(&self, ip: IpAddr) -> Option<BanRecord> {
559        let ip = canonical(ip);
560        let now = (self.clock)();
561        let mut bans = self.bans.lock().expect("ban store");
562        match bans.get(&ip) {
563            Some(record) if now <= record.expiry => Some(record.clone()),
564            Some(_) => {
565                bans.pop(&ip);
566                None
567            }
568            None => None,
569        }
570    }
571
572    /// Lift `ip`'s ban, live or expired (`unban` in the references).
573    pub fn unban(&self, ip: IpAddr) {
574        self.bans.lock().expect("ban store").pop(&canonical(ip));
575    }
576
577    /// Drop every ban and every violation count (`reset` in the
578    /// references; test harnesses use it to isolate cases).
579    pub fn reset(&self) {
580        self.bans.lock().expect("ban store").clear();
581    }
582
583    /// How many bans the store currently holds, live or expired (test and
584    /// observability seam for the LRU cap).
585    #[must_use]
586    pub fn banned_count(&self) -> usize {
587        self.bans.lock().expect("ban store").len()
588    }
589
590    /// The self-DoS guard: loopback and trusted-proxy targets are refused.
591    fn self_ban_refusal(&self, ip: &IpAddr) -> bool {
592        LOOPBACK_NETWORKS
593            .iter()
594            .filter_map(|entry| parse_network_entry(entry))
595            .chain(self.trusted_proxies.iter().copied())
596            .any(|network| network.contains(*ip))
597    }
598}
599
600/// Per-IP per-category violation counts: the reference's
601/// `suspicious_request_counts`, the store the auto-ban engine resolves
602/// thresholds against.
603///
604/// The store is an LRU capped at [`MAX_TRACKED_SUSPICIOUS_IPS`] IPs (the
605/// reference evicts the oldest IP at the same cap), so attacker IP
606/// cardinality cannot grow it without bound.
607pub struct ViolationCounters {
608    counts: Arc<Mutex<LruCache<IpAddr, HashMap<String, u64>>>>,
609}
610
611impl Clone for ViolationCounters {
612    /// A clone shares the count store: violations recorded through any
613    /// handle are visible to every handle.
614    fn clone(&self) -> Self {
615        Self {
616            counts: Arc::clone(&self.counts),
617        }
618    }
619}
620
621impl core::fmt::Debug for ViolationCounters {
622    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
623        f.debug_struct("ViolationCounters").finish_non_exhaustive()
624    }
625}
626
627impl Default for ViolationCounters {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633impl ViolationCounters {
634    /// Build an empty counter store.
635    #[must_use]
636    pub fn new() -> Self {
637        Self {
638            counts: Arc::new(Mutex::new(LruCache::new(
639                NonZeroUsize::new(MAX_TRACKED_SUSPICIOUS_IPS).expect("constant above zero"),
640            ))),
641        }
642    }
643
644    /// Record one violation per category for `ip` (each listed category
645    /// gains `+1`; the reference maps an empty category list to the
646    /// `uncategorized` pseudo-category, mirrored here).
647    pub fn record(&self, ip: IpAddr, categories: &[&str]) {
648        let categories: &[&str] = if categories.is_empty() {
649            &["uncategorized"]
650        } else {
651            categories
652        };
653        let mut counts = self.counts.lock().expect("violation counter store");
654        let ip_counts = counts
655            .try_get_or_insert_mut(canonical(ip), || {
656                Ok::<_, core::convert::Infallible>(HashMap::new())
657            })
658            .expect("ip capacity just reserved");
659        // The write loop is the guard's only lifetime reason: it ends here.
660        for category in categories {
661            *ip_counts.entry((*category).to_owned()).or_insert(0) += 1;
662        }
663        drop(counts);
664    }
665
666    /// A copy of `ip`'s counts (the resolution reads a snapshot while the
667    /// store stays locked for the next writer).
668    #[must_use]
669    pub fn snapshot(&self, ip: IpAddr) -> HashMap<String, u64> {
670        self.counts
671            .lock()
672            .expect("violation counter store")
673            .peek(&canonical(ip))
674            .cloned()
675            .unwrap_or_default()
676    }
677
678    /// Drop every count.
679    pub fn reset(&self) {
680        self.counts.lock().expect("violation counter store").clear();
681    }
682
683    /// How many IPs the store currently tracks (test/observability seam).
684    #[must_use]
685    pub fn tracked_ips(&self) -> usize {
686        self.counts.lock().expect("violation counter store").len()
687    }
688}
689
690/// The ban a threshold resolution decided on: how long, under what reason,
691/// and which category crossed (none for the flat fallback).
692#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct ResolvedBan {
694    /// Ban length in seconds (the crossed entry's or the flat default's).
695    pub duration: u64,
696    /// The reason to record (`"reason"` for the flat fallback,
697    /// `"reason:category"` for a crossed entry, exactly as the references
698    /// build it).
699    pub reason: String,
700    /// The category whose entry crossed, `None` for the flat fallback.
701    pub category: Option<String>,
702}
703
704/// The reference threshold resolution against one IP's current counts.
705///
706/// This is `_resolve_and_apply_threshold_ban` (the TypeScript port's
707/// `resolveThresholdBan`): per-category entries first, in the order the
708/// categories are listed, then the flat threshold against the total.
709///
710/// Banning disabled always resolves to no ban.
711#[must_use]
712pub fn resolve_threshold_ban<S: core::hash::BuildHasher>(
713    counts: &HashMap<String, u64, S>,
714    config: &IpBanConfig,
715    threat_categories: &[&str],
716    reason: &str,
717) -> Option<ResolvedBan> {
718    if !config.enable_ip_banning {
719        return None;
720    }
721    for category in threat_categories {
722        let Some(entry) = config.threat_ban_config.get(*category) else {
723            continue;
724        };
725        let count = counts.get(*category).copied().unwrap_or(0);
726        if count < u64::from(entry.threshold) {
727            continue;
728        }
729        return Some(ResolvedBan {
730            duration: entry.duration,
731            reason: format!("{reason}:{category}"),
732            category: Some((*category).to_owned()),
733        });
734    }
735    let total: u64 = counts.values().sum();
736    if total < u64::from(config.auto_ban_threshold) {
737        return None;
738    }
739    Some(ResolvedBan {
740        duration: config.auto_ban_duration,
741        reason: reason.to_owned(),
742        category: None,
743    })
744}
745
746impl IpBanManager {
747    /// The auto-ban engine's one-call shape: record the categories for
748    /// `ip`, resolve the thresholds, and apply the ban when one crossed.
749    ///
750    /// `Some(..)` means a ban now stands (the stage answers with the
751    /// family's banned shape), `None` means nothing fired - banning
752    /// disabled, no threshold crossed, or the self-DoS guard refusing the
753    /// target. Violations are counted regardless of the banning switch,
754    /// mirroring the reference: enabling banning later starts from observed
755    /// history.
756    #[must_use]
757    pub fn register_violations(
758        &self,
759        counters: &ViolationCounters,
760        ip: IpAddr,
761        categories: &[&str],
762        config: &IpBanConfig,
763        reason: &str,
764    ) -> Option<ResolvedBan> {
765        counters.record(ip, categories);
766        let counts = counters.snapshot(ip);
767        let resolved = resolve_threshold_ban(&counts, config, categories, reason)?;
768        match self.ban_ip(ip, resolved.duration, &resolved.reason) {
769            Ok(true) => Some(resolved),
770            _ => None,
771        }
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::str::FromStr;
779    use std::sync::atomic::{AtomicU64, Ordering};
780
781    /// A fake clock: f64 unix seconds starting at `1_000.0`, advanced by
782    /// `advance`.
783    #[derive(Clone, Default)]
784    struct FakeClock(Arc<AtomicU64>);
785
786    impl FakeClock {
787        fn advance(&self, seconds: u64) {
788            self.0.fetch_add(seconds, Ordering::Relaxed);
789        }
790
791        fn clock(&self) -> Clock {
792            let state = self.0.clone();
793            #[allow(clippy::cast_precision_loss)]
794            Arc::new(move || state.load(Ordering::Relaxed) as f64)
795        }
796    }
797
798    fn ip(text: &str) -> IpAddr {
799        IpAddr::from_str(text).expect("test address")
800    }
801
802    #[test]
803    fn default_config_matches_the_reference() {
804        let config = IpBanConfig::default();
805        assert!(config.enable_ip_banning);
806        assert_eq!(config.auto_ban_threshold, 10);
807        assert_eq!(config.auto_ban_duration, 3600);
808        assert!(config.threat_ban_config.is_empty());
809    }
810
811    #[test]
812    fn config_validation_fails_closed() {
813        let error =
814            IpBanConfig::new(true, 0, 3600, Vec::<(String, ThreatBanEntry)>::new()).unwrap_err();
815        assert_eq!(
816            error,
817            IpBanConfigError::NonPositive {
818                field: "auto_ban_threshold"
819            }
820        );
821        assert_eq!(
822            error.to_string(),
823            "invalid auto_ban_threshold: must be at least 1"
824        );
825
826        let error =
827            IpBanConfig::new(true, 10, 0, Vec::<(String, ThreatBanEntry)>::new()).unwrap_err();
828        assert_eq!(
829            error,
830            IpBanConfigError::NonPositive {
831                field: "auto_ban_duration"
832            }
833        );
834
835        let error = IpBanConfig::new(
836            true,
837            10,
838            3600,
839            [(
840                "not_a_category",
841                ThreatBanEntry {
842                    threshold: 1,
843                    duration: 1,
844                },
845            )],
846        )
847        .unwrap_err();
848        assert_eq!(
849            error,
850            IpBanConfigError::UnknownCategory {
851                category: "not_a_category".to_owned()
852            }
853        );
854        assert_eq!(
855            error.to_string(),
856            "unknown threat category 'not_a_category': expected a detection category or 'rate_limit'"
857        );
858    }
859
860    #[test]
861    fn config_accepts_table_categories_and_rate_limit() {
862        IpBanConfig::new(
863            true,
864            10,
865            3600,
866            [
867                (
868                    "sqli",
869                    ThreatBanEntry {
870                        threshold: 3,
871                        duration: 60,
872                    },
873                ),
874                (
875                    RATE_LIMIT_CATEGORY,
876                    ThreatBanEntry {
877                        threshold: 5,
878                        duration: 30,
879                    },
880                ),
881            ],
882        )
883        .expect("both categories are valid");
884        assert!(valid_threat_categories().contains("sqli"));
885        assert!(valid_threat_categories().contains(RATE_LIMIT_CATEGORY));
886    }
887
888    #[test]
889    fn ban_then_lookup_then_expiry() {
890        let fake = FakeClock::default();
891        let manager = IpBanManager::with_clock(fake.clock());
892        manager
893            .ban_ip(ip("192.0.2.9"), 60, "threshold_exceeded")
894            .expect("ban");
895        assert!(manager.is_banned(ip("192.0.2.9")));
896        assert_eq!(
897            manager
898                .ban_record(ip("192.0.2.9"))
899                .expect("live record")
900                .reason,
901            "threshold_exceeded"
902        );
903
904        // Just before expiry the ban still stands.
905        fake.advance(59);
906        assert!(manager.is_banned(ip("192.0.2.9")));
907        // At the expiry second (`now <= expiry`) it still stands; strictly
908        // past it the reference boundary (`now > expiry` evicts) releases.
909        fake.advance(1);
910        assert!(manager.is_banned(ip("192.0.2.9")));
911        fake.advance(1);
912        assert!(!manager.is_banned(ip("192.0.2.9")));
913        assert_eq!(manager.banned_count(), 0, "the expired entry was dropped");
914    }
915
916    #[test]
917    fn rebanning_overwrites_expiry_and_reason() {
918        let manager = IpBanManager::new();
919        manager.ban_ip(ip("192.0.2.9"), 60, "first").expect("ban");
920        manager.ban_ip(ip("192.0.2.9"), 120, "second").expect("ban");
921        let record = manager.ban_record(ip("192.0.2.9")).expect("live record");
922        assert_eq!(record.reason, "second");
923    }
924
925    #[test]
926    fn zero_duration_is_an_error() {
927        let manager = IpBanManager::new();
928        assert_eq!(
929            manager.ban_ip(ip("192.0.2.9"), 0, "nope").unwrap_err(),
930            BanError::NonPositiveDuration
931        );
932        assert!(!manager.is_banned(ip("192.0.2.9")));
933        assert_eq!(
934            BanError::NonPositiveDuration.to_string(),
935            "ban duration must be positive"
936        );
937    }
938
939    #[test]
940    fn loopback_is_refused_for_both_families() {
941        let manager = IpBanManager::new();
942        for loopback in ["127.0.0.1", "127.8.8.8", "::1"] {
943            assert!(
944                !manager
945                    .ban_ip(ip(loopback), 60, "mistake")
946                    .expect("refusal is not an error"),
947                "{loopback} must be refused"
948            );
949            assert!(!manager.is_banned(ip(loopback)));
950        }
951    }
952
953    #[test]
954    fn trusted_proxies_are_refused_and_parsed_fail_closed() {
955        let manager = IpBanManager::with_trusted_proxies(["10.0.0.0/8", "2001:db8:ffff::/48"])
956            .expect("valid");
957        assert!(
958            !manager
959                .ban_ip(ip("10.1.2.3"), 60, "proxy")
960                .expect("refusal")
961        );
962        assert!(
963            !manager
964                .ban_ip(ip("2001:db8:ffff::1"), 60, "proxy")
965                .expect("refusal")
966        );
967        assert!(
968            manager
969                .ban_ip(ip("11.1.2.3"), 60, "outside the proxy range")
970                .expect("ban"),
971            "an address outside the proxy range is bannable"
972        );
973
974        let error = IpBanManager::with_trusted_proxies(["not-an-ip"]).unwrap_err();
975        assert_eq!(error.list, "trusted_proxies");
976        assert_eq!(error.entry, "not-an-ip");
977    }
978
979    #[test]
980    fn ipv4_mapped_targets_canonicalize() {
981        let manager = IpBanManager::new();
982        // The mapped loopback form is the loopback: refused.
983        assert!(
984            !manager
985                .ban_ip(ip("::ffff:127.0.0.1"), 60, "mistake")
986                .expect("refusal")
987        );
988        // A mapped ban and its IPv4 form are one entry.
989        manager
990            .ban_ip(ip("::ffff:192.0.2.9"), 60, "x")
991            .expect("ban");
992        assert!(manager.is_banned(ip("192.0.2.9")));
993        assert_eq!(manager.banned_count(), 1);
994    }
995
996    #[test]
997    fn unban_and_reset() {
998        let manager = IpBanManager::new();
999        manager.ban_ip(ip("192.0.2.9"), 60, "x").expect("ban");
1000        manager.ban_ip(ip("192.0.2.8"), 60, "x").expect("ban");
1001        manager.unban(ip("192.0.2.9"));
1002        assert!(!manager.is_banned(ip("192.0.2.9")));
1003        assert!(manager.is_banned(ip("192.0.2.8")));
1004        manager.reset();
1005        assert!(!manager.is_banned(ip("192.0.2.8")));
1006        assert_eq!(manager.banned_count(), 0);
1007    }
1008
1009    #[test]
1010    fn durations_beyond_the_local_cap_are_clamped() {
1011        let fake = FakeClock::default();
1012        let manager = IpBanManager::with_clock(fake.clock());
1013        manager.ban_ip(ip("192.0.2.9"), 7200, "long").expect("ban");
1014        // The expiry is the local cap, not the requested duration.
1015        fake.advance(3600);
1016        assert!(manager.is_banned(ip("192.0.2.9")));
1017        fake.advance(1);
1018        assert!(!manager.is_banned(ip("192.0.2.9")));
1019    }
1020
1021    #[test]
1022    fn counters_accumulate_per_ip_per_category() {
1023        let counters = ViolationCounters::new();
1024        let attacker = ip("192.0.2.9");
1025        counters.record(attacker, &["sqli"]);
1026        counters.record(attacker, &["sqli", "xss"]);
1027        counters.record(ip("192.0.2.8"), &["xss"]);
1028
1029        let counts = counters.snapshot(attacker);
1030        assert_eq!(counts.get("sqli"), Some(&2));
1031        assert_eq!(counts.get("xss"), Some(&1));
1032        assert_eq!(counters.snapshot(ip("192.0.2.8")).get("xss"), Some(&1));
1033        assert_eq!(counters.tracked_ips(), 2);
1034
1035        // The empty category list records the uncategorized pseudo-category,
1036        // exactly like the reference's `_increment_suspicious_counts`.
1037        counters.record(attacker, &[]);
1038        assert_eq!(counters.snapshot(attacker).get("uncategorized"), Some(&1));
1039
1040        counters.reset();
1041        assert_eq!(counters.tracked_ips(), 0);
1042    }
1043
1044    #[test]
1045    fn ipv4_mapped_violations_share_the_ipv4_counts() {
1046        let counters = ViolationCounters::new();
1047        counters.record(ip("::ffff:192.0.2.9"), &["sqli"]);
1048        assert_eq!(counters.snapshot(ip("192.0.2.9")).get("sqli"), Some(&1));
1049    }
1050
1051    #[test]
1052    fn resolution_disabled_never_bans() {
1053        let counts = HashMap::from([("sqli".to_owned(), 99)]);
1054        let config = IpBanConfig {
1055            enable_ip_banning: false,
1056            ..IpBanConfig::default()
1057        };
1058        assert!(
1059            resolve_threshold_ban(&counts, &config, &["sqli"], "penetration_attempt").is_none()
1060        );
1061    }
1062
1063    #[test]
1064    fn resolution_category_entry_wins_in_order() {
1065        let counts = HashMap::from([("sqli".to_owned(), 3), ("xss".to_owned(), 10)]);
1066        let config = IpBanConfig::new(
1067            true,
1068            10,
1069            3600,
1070            [
1071                (
1072                    "sqli",
1073                    ThreatBanEntry {
1074                        threshold: 3,
1075                        duration: 60,
1076                    },
1077                ),
1078                (
1079                    "xss",
1080                    ThreatBanEntry {
1081                        threshold: 5,
1082                        duration: 30,
1083                    },
1084                ),
1085            ],
1086        )
1087        .expect("valid config");
1088
1089        let resolved =
1090            resolve_threshold_ban(&counts, &config, &["sqli", "xss"], "penetration_attempt")
1091                .expect("sqli crosses first in list order");
1092        assert_eq!(resolved.category.as_deref(), Some("sqli"));
1093        assert_eq!(resolved.duration, 60);
1094        assert_eq!(resolved.reason, "penetration_attempt:sqli");
1095
1096        // Listed the other way round, the xss entry wins.
1097        let resolved =
1098            resolve_threshold_ban(&counts, &config, &["xss", "sqli"], "penetration_attempt")
1099                .expect("xss crosses first in list order");
1100        assert_eq!(resolved.category.as_deref(), Some("xss"));
1101        assert_eq!(resolved.duration, 30);
1102    }
1103
1104    #[test]
1105    fn resolution_below_every_entry_falls_back_to_the_flat_total() {
1106        let config = IpBanConfig::new(
1107            true,
1108            10,
1109            3600,
1110            [(
1111                "sqli",
1112                ThreatBanEntry {
1113                    threshold: 5,
1114                    duration: 60,
1115                },
1116            )],
1117        )
1118        .expect("valid config");
1119
1120        // No category at its entry threshold, total 8 < 10: nothing.
1121        let counts = HashMap::from([("sqli".to_owned(), 4), ("xss".to_owned(), 4)]);
1122        assert!(
1123            resolve_threshold_ban(&counts, &config, &["sqli"], "penetration_attempt").is_none()
1124        );
1125
1126        // Still no entry crossing, but the total reaches the flat threshold.
1127        let counts = HashMap::from([("sqli".to_owned(), 4), ("xss".to_owned(), 6)]);
1128        let resolved = resolve_threshold_ban(&counts, &config, &["sqli"], "penetration_attempt")
1129            .expect("the flat threshold fires on the total");
1130        assert_eq!(resolved.category, None);
1131        assert_eq!(resolved.duration, 3600);
1132        assert_eq!(resolved.reason, "penetration_attempt");
1133    }
1134
1135    #[test]
1136    fn resolution_ignores_uncounted_entries() {
1137        let config = IpBanConfig::new(
1138            true,
1139            10,
1140            3600,
1141            [(
1142                "xss",
1143                ThreatBanEntry {
1144                    threshold: 1,
1145                    duration: 60,
1146                },
1147            )],
1148        )
1149        .expect("valid config");
1150        // sqli has no entry and no count: it neither bans nor blocks the
1151        // flat fallback.
1152        let counts = HashMap::from([("sqli".to_owned(), 4)]);
1153        assert!(
1154            resolve_threshold_ban(&counts, &config, &["sqli"], "penetration_attempt").is_none()
1155        );
1156    }
1157
1158    #[test]
1159    fn register_violations_counts_then_bans_at_the_threshold() {
1160        let fake = FakeClock::default();
1161        let manager = IpBanManager::with_clock(fake.clock());
1162        let counters = ViolationCounters::new();
1163        let attacker = ip("192.0.2.9");
1164        let config = IpBanConfig::new(
1165            true,
1166            100,
1167            3600,
1168            [(
1169                "sqli",
1170                ThreatBanEntry {
1171                    threshold: 3,
1172                    duration: 120,
1173                },
1174            )],
1175        )
1176        .expect("valid config");
1177
1178        assert!(
1179            manager
1180                .register_violations(
1181                    &counters,
1182                    attacker,
1183                    &["sqli"],
1184                    &config,
1185                    "penetration_attempt"
1186                )
1187                .is_none()
1188        );
1189        assert!(
1190            manager
1191                .register_violations(
1192                    &counters,
1193                    attacker,
1194                    &["sqli"],
1195                    &config,
1196                    "penetration_attempt"
1197                )
1198                .is_none()
1199        );
1200        assert!(
1201            !manager.is_banned(attacker),
1202            "below the threshold nobody is banned"
1203        );
1204
1205        let resolved = manager
1206            .register_violations(
1207                &counters,
1208                attacker,
1209                &["sqli"],
1210                &config,
1211                "penetration_attempt",
1212            )
1213            .expect("the third violation crosses the entry");
1214        assert_eq!(resolved.category.as_deref(), Some("sqli"));
1215        assert_eq!(resolved.reason, "penetration_attempt:sqli");
1216        assert!(manager.is_banned(attacker));
1217        assert_eq!(
1218            manager.ban_record(attacker).expect("record").reason,
1219            "penetration_attempt:sqli"
1220        );
1221    }
1222
1223    #[test]
1224    fn register_violations_without_banning_still_counts() {
1225        let manager = IpBanManager::new();
1226        let counters = ViolationCounters::new();
1227        let attacker = ip("192.0.2.9");
1228        let config = IpBanConfig {
1229            enable_ip_banning: false,
1230            ..IpBanConfig::default()
1231        };
1232
1233        for _ in 0..(config.auto_ban_threshold + 5) {
1234            assert!(
1235                manager
1236                    .register_violations(
1237                        &counters,
1238                        attacker,
1239                        &["xss"],
1240                        &config,
1241                        "penetration_attempt"
1242                    )
1243                    .is_none()
1244            );
1245        }
1246        assert!(!manager.is_banned(attacker), "banning is off");
1247        assert!(
1248            counters.snapshot(attacker).values().sum::<u64>()
1249                >= u64::from(config.auto_ban_threshold),
1250            "the violations were counted anyway"
1251        );
1252    }
1253
1254    #[test]
1255    fn register_violations_refusal_counts_but_does_not_ban() {
1256        let manager = IpBanManager::new();
1257        let counters = ViolationCounters::new();
1258        let config = IpBanConfig::new(true, 1, 3600, Vec::<(String, ThreatBanEntry)>::new())
1259            .expect("valid config");
1260        // The flat threshold is 1, but the target is loopback: refused.
1261        assert!(
1262            manager
1263                .register_violations(
1264                    &counters,
1265                    ip("127.0.0.1"),
1266                    &["xss"],
1267                    &config,
1268                    "penetration_attempt"
1269                )
1270                .is_none()
1271        );
1272        assert!(!manager.is_banned(ip("127.0.0.1")));
1273        assert_eq!(counters.snapshot(ip("127.0.0.1")).get("xss"), Some(&1));
1274    }
1275}