1use 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
96pub const DEFAULT_AUTO_BAN_THRESHOLD: u32 = 10;
99pub const DEFAULT_AUTO_BAN_DURATION: u64 = 3600;
102pub const LOCAL_CACHE_TTL_CAP_SECONDS: u64 = 3600;
105pub const LOCAL_CACHE_MAX_SIZE: usize = 10_000;
108pub const MAX_TRACKED_SUSPICIOUS_IPS: usize = 10_000;
110
111pub const RATE_LIMIT_CATEGORY: &str = "rate_limit";
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct ThreatBanEntry {
119 pub threshold: u32,
121 pub duration: u64,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct IpBanConfig {
133 pub enable_ip_banning: bool,
137 pub auto_ban_threshold: u32,
140 pub auto_ban_duration: u64,
142 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#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum IpBanConfigError {
162 NonPositive {
164 field: &'static str,
166 },
167 UnknownCategory {
171 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#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum BanError {
269 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#[derive(Debug, Clone, PartialEq)]
288pub struct BanRecord {
289 pub expiry: f64,
291 pub reason: String,
293}
294
295pub 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
307fn 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
315pub 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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 pub fn unban(&self, ip: IpAddr) {
574 self.bans.lock().expect("ban store").pop(&canonical(ip));
575 }
576
577 pub fn reset(&self) {
580 self.bans.lock().expect("ban store").clear();
581 }
582
583 #[must_use]
586 pub fn banned_count(&self) -> usize {
587 self.bans.lock().expect("ban store").len()
588 }
589
590 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
600pub struct ViolationCounters {
608 counts: Arc<Mutex<LruCache<IpAddr, HashMap<String, u64>>>>,
609}
610
611impl Clone for ViolationCounters {
612 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 #[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 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 for category in categories {
661 *ip_counts.entry((*category).to_owned()).or_insert(0) += 1;
662 }
663 drop(counts);
664 }
665
666 #[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 pub fn reset(&self) {
680 self.counts.lock().expect("violation counter store").clear();
681 }
682
683 #[must_use]
685 pub fn tracked_ips(&self) -> usize {
686 self.counts.lock().expect("violation counter store").len()
687 }
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct ResolvedBan {
694 pub duration: u64,
696 pub reason: String,
700 pub category: Option<String>,
702}
703
704#[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 #[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 #[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 fake.advance(59);
906 assert!(manager.is_banned(ip("192.0.2.9")));
907 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 assert!(
984 !manager
985 .ban_ip(ip("::ffff:127.0.0.1"), 60, "mistake")
986 .expect("refusal")
987 );
988 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 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 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 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 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 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 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 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}