Skip to main content

waf_schema_enforcement/
bot.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Client-integrity / bot detection (§4-A). Native cross-header heuristics that a single regex
5//! cannot express — deliberately NOT the OPEN `scanner` module (which matches offensive-tool names
6//! in the User-Agent) nor curated content (CRS-curated §6).
7//!
8//! **v1 signal — browser impersonation (S1).** A request whose User-Agent advertises a *modern*
9//! browser (Chromium/Firefox with a 3-digit major version → 2022+) but which carries **none** of the
10//! `sec-fetch-*` Fetch-Metadata headers that browser always sends. A crude bot spoofing a Chrome UA
11//! via `curl`/`requests` omits them; a legitimate non-browser client (curl/SDK) does not advertise a
12//! browser at all, so it is not flagged. Additive `Decision::Scores` (Critical, tunable) — never a
13//! hard reject: the FP source is old/edge browsers or header-stripping proxies, governed by severity.
14//!
15//! **STEP 1–2 (scaffold + config).** The crate carries the `bot-*` registry leaf, the UA classifier,
16//! the S1 rule and its config, proven on the real `Pipeline`. Safari is excluded in v1 (historically
17//! inconsistent Sec-Fetch). Unlike the schema modules there is no file to load, so construction is
18//! infallible (no boot-fatal). The `[enterprise.bot_detection]` wiring lands in Step 5.
19
20use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
21
22use crate::rules;
23
24/// Configuration for the bot-detection module. Built by the enterprise wiring from
25/// `[enterprise.bot_detection]` (Step 5). No file/schema, no Redis — the heuristics are native.
26#[derive(Debug, Clone)]
27pub struct BotDetectionConfig {
28    /// Off by default → the module is inert.
29    pub enabled: bool,
30    /// Severity contributed by the browser-impersonation signal (S1). Default Critical.
31    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
40/// True if `ua` advertises a **modern** Chromium or Firefox browser — a 3-digit major version
41/// (`>= 100`, i.e. 2022+), which always emits Fetch-Metadata (`Sec-Fetch-*`). Safari is intentionally
42/// NOT matched (its Sec-Fetch support was historically inconsistent → would be a false-positive
43/// source). Chromium-based Edge/Opera/etc. carry the `Chrome/1xx` token and are covered.
44pub 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
48/// Whether `ua` contains `<token><major>` with a 3+ digit major version.
49fn 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
56/// The User-Agent header value (empty if absent). Read from the normalized headers (names
57/// lowercased, values preserved).
58fn 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
67/// Whether the request carries any `sec-fetch-*` header (Fetch Metadata).
68fn has_sec_fetch(ctx: &RequestContext) -> bool {
69    ctx.normalized.headers.iter().any(|(k, _)| k.starts_with("sec-fetch-"))
70}
71
72/// Bot-detection module. Scores a request that impersonates a browser (S1).
73pub struct BotDetectionModule {
74    enabled: bool,
75    impersonation_severity: Severity,
76}
77
78impl BotDetectionModule {
79    /// Production constructor. Infallible — there is no schema/file to load (native heuristics).
80    pub fn from_config(cfg: &BotDetectionConfig) -> Self {
81        Self { enabled: cfg.enabled, impersonation_severity: cfg.browser_impersonation_severity }
82    }
83
84    /// Test/embedding constructor (default Critical severity).
85    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    /// Structural: the verdict is a cross-header heuristic, not a content-rule match, so the content
100    /// fast-path must not skip it.
101    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        // S1: UA claims a modern browser but the request carries no Fetch-Metadata → impersonation.
112        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)); // Chromium token
140    }
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("")); // no UA
147        assert!(!is_modern_browser_ua("Mozilla/5.0 ... Chrome/79.0.3945.88 Safari/537.36")); // 2-digit
148        assert!(!is_modern_browser_ua(SAFARI)); // Safari excluded (no Chrome/Firefox token)
149    }
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}