waf_schema_enforcement/
bot.rs1use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
21
22use crate::rules;
23
24#[derive(Debug, Clone)]
27pub struct BotDetectionConfig {
28 pub enabled: bool,
30 pub browser_impersonation_severity: Severity,
32}
33
34impl Default for BotDetectionConfig {
35 fn default() -> Self {
36 Self { enabled: false, browser_impersonation_severity: Severity::Critical }
37 }
38}
39
40pub fn is_modern_browser_ua(ua: &str) -> bool {
45 ua_token_major_ge_100(ua, "Chrome/") || ua_token_major_ge_100(ua, "Firefox/")
46}
47
48fn ua_token_major_ge_100(ua: &str, token: &str) -> bool {
50 ua.match_indices(token).any(|(i, _)| {
51 let rest = &ua[i + token.len()..];
52 rest.chars().take_while(|c| c.is_ascii_digit()).count() >= 3
53 })
54}
55
56fn user_agent(ctx: &RequestContext) -> &str {
59 ctx.normalized
60 .headers
61 .iter()
62 .find(|(k, _)| k == "user-agent")
63 .map(|(_, v)| v.as_str())
64 .unwrap_or("")
65}
66
67fn has_sec_fetch(ctx: &RequestContext) -> bool {
69 ctx.normalized.headers.iter().any(|(k, _)| k.starts_with("sec-fetch-"))
70}
71
72pub struct BotDetectionModule {
74 enabled: bool,
75 impersonation_severity: Severity,
76}
77
78impl BotDetectionModule {
79 pub fn from_config(cfg: &BotDetectionConfig) -> Self {
81 Self { enabled: cfg.enabled, impersonation_severity: cfg.browser_impersonation_severity }
82 }
83
84 pub fn new(enabled: bool) -> Self {
86 Self::from_config(&BotDetectionConfig { enabled, ..Default::default() })
87 }
88}
89
90impl WafModule for BotDetectionModule {
91 fn id(&self) -> &str {
92 "bot-detection"
93 }
94
95 fn phase(&self) -> Phase {
96 Phase::Headers
97 }
98
99 fn structural(&self) -> bool {
102 true
103 }
104
105 fn init(&mut self, _cfg: &Config) {}
106
107 fn inspect(&self, ctx: &RequestContext) -> Decision {
108 if !self.enabled {
109 return Decision::Allow;
110 }
111 if is_modern_browser_ua(user_agent(ctx)) && !has_sec_fetch(ctx) {
113 return Decision::Scores(vec![ScoreItem {
114 rule_id: rules::BOT_BROWSER_IMPERSONATION.id(),
115 severity: self.impersonation_severity,
116 }]);
117 }
118 Decision::Allow
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 const CHROME: &str =
127 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
128 Chrome/120.0.0.0 Safari/537.36";
129 const FIREFOX: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0";
130 const EDGE: &str = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) \
131 Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
132 const SAFARI: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 \
133 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
134
135 #[test]
136 fn classifies_modern_browsers() {
137 assert!(is_modern_browser_ua(CHROME));
138 assert!(is_modern_browser_ua(FIREFOX));
139 assert!(is_modern_browser_ua(EDGE)); }
141
142 #[test]
143 fn excludes_non_browsers_old_browsers_and_safari() {
144 assert!(!is_modern_browser_ua("curl/8.4.0"));
145 assert!(!is_modern_browser_ua("python-requests/2.31.0"));
146 assert!(!is_modern_browser_ua("")); assert!(!is_modern_browser_ua("Mozilla/5.0 ... Chrome/79.0.3945.88 Safari/537.36")); assert!(!is_modern_browser_ua(SAFARI)); }
150
151 #[test]
152 fn default_config_is_off_with_critical_severity() {
153 let c = BotDetectionConfig::default();
154 assert!(!c.enabled);
155 assert_eq!(c.browser_impersonation_severity, Severity::Critical);
156 }
157
158 #[test]
159 fn from_config_carries_enabled_and_severity() {
160 let cfg = BotDetectionConfig {
161 enabled: true,
162 browser_impersonation_severity: Severity::Warning,
163 };
164 let m = BotDetectionModule::from_config(&cfg);
165 assert!(m.enabled);
166 assert_eq!(m.impersonation_severity, Severity::Warning);
167 }
168}