millipede_core/
antibot.rs1use crate::errors::AntiBotTech;
4use http::{HeaderMap, StatusCode};
5use url::Url;
6
7#[non_exhaustive]
9pub struct AntiBotSignals<'a> {
10 pub status: StatusCode,
12 pub headers: &'a HeaderMap,
14 pub body: &'a [u8],
16 pub final_url: &'a Url,
18}
19
20impl<'a> AntiBotSignals<'a> {
21 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
37pub trait AntiBotDetector: Send + Sync + std::fmt::Debug + 'static {
39 fn detect(&self, signals: &AntiBotSignals<'_>) -> Option<AntiBotTech>;
41}
42
43#[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 pub const DEFAULT_INSPECTION_LIMIT: usize = 64 * 1024;
54
55 pub fn new() -> Self {
57 Self {
58 inspection_limit: Self::DEFAULT_INSPECTION_LIMIT,
59 custom_markers: Vec::new(),
60 }
61 }
62
63 pub fn with_inspection_limit(mut self, inspection_limit: usize) -> Self {
65 self.inspection_limit = inspection_limit;
66 self
67 }
68
69 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}