use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
use crate::rules;
#[derive(Debug, Clone)]
pub struct BotDetectionConfig {
pub enabled: bool,
pub browser_impersonation_severity: Severity,
}
impl Default for BotDetectionConfig {
fn default() -> Self {
Self { enabled: false, browser_impersonation_severity: Severity::Critical }
}
}
pub fn is_modern_browser_ua(ua: &str) -> bool {
ua_token_major_ge_100(ua, "Chrome/") || ua_token_major_ge_100(ua, "Firefox/")
}
fn ua_token_major_ge_100(ua: &str, token: &str) -> bool {
ua.match_indices(token).any(|(i, _)| {
let rest = &ua[i + token.len()..];
rest.chars().take_while(|c| c.is_ascii_digit()).count() >= 3
})
}
fn user_agent(ctx: &RequestContext) -> &str {
ctx.normalized
.headers
.iter()
.find(|(k, _)| k == "user-agent")
.map(|(_, v)| v.as_str())
.unwrap_or("")
}
fn has_sec_fetch(ctx: &RequestContext) -> bool {
ctx.normalized.headers.iter().any(|(k, _)| k.starts_with("sec-fetch-"))
}
pub struct BotDetectionModule {
enabled: bool,
impersonation_severity: Severity,
}
impl BotDetectionModule {
pub fn from_config(cfg: &BotDetectionConfig) -> Self {
Self { enabled: cfg.enabled, impersonation_severity: cfg.browser_impersonation_severity }
}
pub fn new(enabled: bool) -> Self {
Self::from_config(&BotDetectionConfig { enabled, ..Default::default() })
}
}
impl WafModule for BotDetectionModule {
fn id(&self) -> &str {
"bot-detection"
}
fn phase(&self) -> Phase {
Phase::Headers
}
fn structural(&self) -> bool {
true
}
fn init(&mut self, _cfg: &Config) {}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if !self.enabled {
return Decision::Allow;
}
if is_modern_browser_ua(user_agent(ctx)) && !has_sec_fetch(ctx) {
return Decision::Scores(vec![ScoreItem {
rule_id: rules::BOT_BROWSER_IMPERSONATION.id(),
severity: self.impersonation_severity,
}]);
}
Decision::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
const CHROME: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/120.0.0.0 Safari/537.36";
const FIREFOX: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0";
const EDGE: &str = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
const SAFARI: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/17.0 Safari/605.1.15";
#[test]
fn classifies_modern_browsers() {
assert!(is_modern_browser_ua(CHROME));
assert!(is_modern_browser_ua(FIREFOX));
assert!(is_modern_browser_ua(EDGE)); }
#[test]
fn excludes_non_browsers_old_browsers_and_safari() {
assert!(!is_modern_browser_ua("curl/8.4.0"));
assert!(!is_modern_browser_ua("python-requests/2.31.0"));
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)); }
#[test]
fn default_config_is_off_with_critical_severity() {
let c = BotDetectionConfig::default();
assert!(!c.enabled);
assert_eq!(c.browser_impersonation_severity, Severity::Critical);
}
#[test]
fn from_config_carries_enabled_and_severity() {
let cfg = BotDetectionConfig {
enabled: true,
browser_impersonation_severity: Severity::Warning,
};
let m = BotDetectionModule::from_config(&cfg);
assert!(m.enabled);
assert_eq!(m.impersonation_severity, Severity::Warning);
}
}