Skip to main content

millipede_core/
antibot.rs

1//! Content-based anti-bot and web application firewall detection.
2
3use crate::errors::AntiBotTech;
4use http::{HeaderMap, StatusCode};
5use url::Url;
6
7/// Response signals inspected for evidence of an anti-bot challenge.
8#[non_exhaustive]
9pub struct AntiBotSignals<'a> {
10    /// The response status code.
11    pub status: StatusCode,
12    /// The response headers.
13    pub headers: &'a HeaderMap,
14    /// The response body bytes.
15    pub body: &'a [u8],
16    /// The final response URL after redirects.
17    pub final_url: &'a Url,
18}
19
20impl<'a> AntiBotSignals<'a> {
21    /// Creates a collection of response signals for detection.
22    pub fn new(
23        status: StatusCode,
24        headers: &'a HeaderMap,
25        body: &'a [u8],
26        final_url: &'a Url,
27    ) -> Self {
28        Self {
29            status,
30            headers,
31            body,
32            final_url,
33        }
34    }
35}
36
37/// Detects anti-bot or web application firewall responses from response signals.
38pub trait AntiBotDetector: Send + Sync + std::fmt::Debug + 'static {
39    /// Returns the detected technology, or `None` when evidence is insufficient.
40    fn detect(&self, signals: &AntiBotSignals<'_>) -> Option<AntiBotTech>;
41}
42
43/// A conservative detector using bounded, vendor-specific static markers.
44#[derive(Debug, Clone)]
45#[must_use = "detector configuration does nothing unless the detector is installed"]
46pub struct DefaultAntiBotDetector {
47    inspection_limit: usize,
48    custom_markers: Vec<(String, AntiBotTech)>,
49}
50
51impl DefaultAntiBotDetector {
52    /// The maximum number of response-body bytes inspected by default.
53    pub const DEFAULT_INSPECTION_LIMIT: usize = 64 * 1024;
54
55    /// Creates a detector with the default body inspection limit.
56    pub fn new() -> Self {
57        Self {
58            inspection_limit: Self::DEFAULT_INSPECTION_LIMIT,
59            custom_markers: Vec::new(),
60        }
61    }
62
63    /// Sets the maximum number of response-body bytes to inspect.
64    pub fn with_inspection_limit(mut self, inspection_limit: usize) -> Self {
65        self.inspection_limit = inspection_limit;
66        self
67    }
68
69    /// Adds a case-insensitive body marker with a custom technology label.
70    pub fn with_custom_marker(
71        mut self,
72        marker: impl Into<String>,
73        label: impl Into<String>,
74    ) -> Self {
75        self.custom_markers.push((
76            marker.into().to_lowercase(),
77            AntiBotTech::Custom(label.into()),
78        ));
79        self
80    }
81}
82
83impl Default for DefaultAntiBotDetector {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl AntiBotDetector for DefaultAntiBotDetector {
90    fn detect(&self, signals: &AntiBotSignals<'_>) -> Option<AntiBotTech> {
91        let inspected_len = signals.body.len().min(self.inspection_limit);
92        let body = String::from_utf8_lossy(&signals.body[..inspected_len]).to_ascii_lowercase();
93
94        let mut cf_header = false;
95        let mut cloudflare_server = false;
96        let mut cloudflare_cookie = false;
97        let mut datadome_header = false;
98        let mut datadome_cookie = false;
99        let mut perimeterx_cookie = false;
100        let mut kasada_header = false;
101        let mut imperva_header = false;
102        let mut imperva_cookie = false;
103        let mut akamai_header = false;
104        let mut akamai_cookie = false;
105
106        for (name, value) in signals.headers.iter() {
107            let name = name.as_str().to_ascii_lowercase();
108            if name == "cf-ray" || name == "cf-mitigated" {
109                cf_header = true;
110            }
111            if name == "x-datadome" || name == "x-dd-b" {
112                datadome_header = true;
113            }
114            if name == "x-kpsdk-ct" || name == "x-kpsdk-cd" {
115                kasada_header = true;
116            }
117            if name == "x-iinfo" {
118                imperva_header = true;
119            }
120            if name.starts_with("x-akamai") {
121                akamai_header = true;
122            }
123
124            let Ok(value) = value.to_str() else {
125                continue;
126            };
127            let value = value.to_ascii_lowercase();
128
129            if name == "server" && value.contains("cloudflare") {
130                cloudflare_server = true;
131            }
132            if name == "set-cookie" {
133                cloudflare_cookie |= value.contains("__cf_bm") || value.contains("cf_clearance");
134                datadome_cookie |= value.contains("datadome");
135                perimeterx_cookie |= value.contains("_px") || value.contains("_pxhd");
136                imperva_cookie |= value.contains("visid_incap") || value.contains("incap_ses");
137                akamai_cookie |= value.contains("_abck") || value.contains("ak_bmsc");
138            }
139        }
140
141        if cf_header
142            || cloudflare_server
143            || cloudflare_cookie
144            || contains_any(
145                &body,
146                &[
147                    "just a moment",
148                    "cf-chl",
149                    "challenges.cloudflare.com",
150                    "checking your browser",
151                ],
152            )
153        {
154            return Some(AntiBotTech::Cloudflare);
155        }
156
157        if datadome_header
158            || datadome_cookie
159            || contains_any(&body, &["datadome", "geo.captcha-delivery.com"])
160        {
161            return Some(AntiBotTech::DataDome);
162        }
163
164        if perimeterx_cookie || contains_any(&body, &["px-captcha", "perimeterx", "/_px"]) {
165            return Some(AntiBotTech::PerimeterX);
166        }
167
168        if kasada_header || contains_any(&body, &["kpsdk", "kasada"]) {
169            return Some(AntiBotTech::Kasada);
170        }
171
172        if imperva_header
173            || imperva_cookie
174            || contains_any(&body, &["incapsula", "_incap_", "incident id"])
175        {
176            return Some(AntiBotTech::Imperva);
177        }
178
179        if akamai_cookie
180            || akamai_header
181            || contains_any(&body, &["akamai bot manager", "akamaighost"])
182        {
183            return Some(AntiBotTech::Akamai);
184        }
185
186        for (marker, technology) in &self.custom_markers {
187            if body.contains(marker) {
188                return Some(technology.clone());
189            }
190        }
191
192        if body.contains("captcha")
193            && contains_any(
194                &body,
195                &["access denied", "verify you are human", "are you a human"],
196            )
197        {
198            return Some(AntiBotTech::Unknown);
199        }
200
201        None
202    }
203}
204
205fn contains_any(haystack: &str, needles: &[&str]) -> bool {
206    needles.iter().any(|needle| haystack.contains(needle))
207}