cloudscraper_rs/challenges/solvers/
bot_management.rs1use std::time::Duration;
7
8use once_cell::sync::Lazy;
9use rand::Rng;
10use regex::{Regex, RegexBuilder};
11use thiserror::Error;
12
13use crate::challenges::core::{ChallengeResponse, is_cloudflare_response};
14
15use super::{
16 ChallengeSolver, FailureRecorder, FingerprintManager, MitigationPlan, TlsProfileManager,
17};
18
19const DEFAULT_DELAY_MIN_SECS: f32 = 30.0;
20const DEFAULT_DELAY_MAX_SECS: f32 = 60.0;
21
22pub struct BotManagementHandler {
24 delay_min: Duration,
25 delay_max: Duration,
26}
27
28impl BotManagementHandler {
29 pub fn new() -> Self {
30 Self {
31 delay_min: Duration::from_secs_f32(DEFAULT_DELAY_MIN_SECS),
32 delay_max: Duration::from_secs_f32(DEFAULT_DELAY_MAX_SECS),
33 }
34 }
35
36 pub fn with_delay_range(mut self, min: Duration, max: Duration) -> Self {
37 self.delay_min = min;
38 self.delay_max = if max < min { min } else { max };
39 self
40 }
41
42 pub fn is_bot_management(response: &ChallengeResponse<'_>) -> bool {
43 is_cloudflare_response(response)
44 && response.status == 403
45 && BOT_MANAGEMENT_RE.is_match(response.body)
46 }
47
48 pub fn plan(
49 &self,
50 response: &ChallengeResponse<'_>,
51 fingerprint: Option<&mut dyn FingerprintManager>,
52 tls_manager: Option<&mut dyn TlsProfileManager>,
53 state_recorder: Option<&dyn FailureRecorder>,
54 ) -> Result<MitigationPlan, BotManagementError> {
55 if !Self::is_bot_management(response) {
56 return Err(BotManagementError::NotBotManagement);
57 }
58
59 let domain = response
60 .url
61 .host_str()
62 .ok_or(BotManagementError::MissingHost)?
63 .to_string();
64
65 if let Some(recorder) = state_recorder {
66 recorder.record_failure(&domain, "cf_bot_management");
67 }
68
69 let delay = self.random_delay();
70 let mut plan = MitigationPlan::retry_after(delay, "bot_management");
71 plan.metadata.insert("trigger".into(), "cf_1010".into());
72
73 if let Some(fingerprint_generator) = fingerprint {
74 fingerprint_generator.invalidate(&domain);
75 plan.metadata
76 .insert("fingerprint_reset".into(), "true".into());
77 } else {
78 plan.metadata
79 .insert("fingerprint_reset".into(), "false".into());
80 }
81
82 if let Some(tls) = tls_manager {
83 tls.rotate_profile(&domain);
84 plan.metadata.insert("tls_rotated".into(), "true".into());
85 } else {
86 plan.metadata.insert("tls_rotated".into(), "false".into());
87 }
88
89 plan.metadata
90 .insert("stealth_mode".into(), "enhanced".into());
91
92 Ok(plan)
93 }
94
95 fn random_delay(&self) -> Duration {
96 if self.delay_max <= self.delay_min {
97 return self.delay_min;
98 }
99 let mut rng = rand::thread_rng();
100 let min = self.delay_min.as_secs_f32();
101 let max = self.delay_max.as_secs_f32();
102 Duration::from_secs_f32(rng.gen_range(min..max))
103 }
104}
105
106impl Default for BotManagementHandler {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl ChallengeSolver for BotManagementHandler {
113 fn name(&self) -> &'static str {
114 "bot_management"
115 }
116}
117
118#[derive(Debug, Error)]
119pub enum BotManagementError {
120 #[error("response is not a Cloudflare bot management challenge")]
121 NotBotManagement,
122 #[error("missing host information on response")]
123 MissingHost,
124}
125
126static BOT_MANAGEMENT_RE: Lazy<Regex> = Lazy::new(|| {
127 RegexBuilder::new(r#"(<span[^>]*class=['"]cf-error-code['"]>1010<|Bot management|has banned you temporarily)"#)
128 .case_insensitive(true)
129 .dot_matches_new_line(true)
130 .build()
131 .expect("invalid bot management regex")
132});
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use http::{HeaderMap, Method, header::SERVER};
138 use std::cell::RefCell;
139 use url::Url;
140
141 struct ResponseFixture {
142 url: Url,
143 headers: HeaderMap,
144 method: Method,
145 body: String,
146 }
147
148 impl ResponseFixture {
149 fn new(body: &str) -> Self {
150 let mut headers = HeaderMap::new();
151 headers.insert(SERVER, "cloudflare".parse().unwrap());
152 Self {
153 url: Url::parse("https://example.com/bot-check").unwrap(),
154 headers,
155 method: Method::GET,
156 body: body.to_string(),
157 }
158 }
159
160 fn response(&self) -> ChallengeResponse<'_> {
161 ChallengeResponse {
162 url: &self.url,
163 status: 403,
164 headers: &self.headers,
165 body: &self.body,
166 request_method: &self.method,
167 }
168 }
169
170 fn domain(&self) -> &str {
171 self.url.host_str().unwrap()
172 }
173 }
174
175 struct StubFingerprint {
176 invalidated: Vec<String>,
177 }
178
179 impl StubFingerprint {
180 fn new() -> Self {
181 Self {
182 invalidated: Vec::new(),
183 }
184 }
185
186 fn was_invalidated(&self, domain: &str) -> bool {
187 self.invalidated.iter().any(|d| d == domain)
188 }
189 }
190
191 impl FingerprintManager for StubFingerprint {
192 fn invalidate(&mut self, domain: &str) {
193 self.invalidated.push(domain.to_string());
194 }
195 }
196
197 struct StubTlsManager {
198 rotated: Vec<String>,
199 }
200
201 impl StubTlsManager {
202 fn new() -> Self {
203 Self {
204 rotated: Vec::new(),
205 }
206 }
207
208 fn was_rotated(&self, domain: &str) -> bool {
209 self.rotated.iter().any(|d| d == domain)
210 }
211 }
212
213 impl TlsProfileManager for StubTlsManager {
214 fn rotate_profile(&mut self, domain: &str) {
215 self.rotated.push(domain.to_string());
216 }
217 }
218
219 struct StubRecorder {
220 calls: RefCell<Vec<(String, String)>>,
221 }
222
223 impl StubRecorder {
224 fn new() -> Self {
225 Self {
226 calls: RefCell::new(Vec::new()),
227 }
228 }
229
230 fn count(&self) -> usize {
231 self.calls.borrow().len()
232 }
233
234 fn recorded(&self, domain: &str, reason: &str) -> bool {
235 self.calls
236 .borrow()
237 .iter()
238 .any(|(d, r)| d == domain && r == reason)
239 }
240 }
241
242 impl FailureRecorder for StubRecorder {
243 fn record_failure(&self, domain: &str, reason: &str) {
244 self.calls
245 .borrow_mut()
246 .push((domain.to_string(), reason.to_string()));
247 }
248 }
249
250 #[test]
251 fn detects_bot_management() {
252 let fixture =
253 ResponseFixture::new("<span class='cf-error-code'>1010</span> Bot management");
254 let response = fixture.response();
255 assert!(BotManagementHandler::is_bot_management(&response));
256 }
257
258 #[test]
259 fn plan_invalidates_fingerprint_and_rotates_tls() {
260 let fixture =
261 ResponseFixture::new("<span class='cf-error-code'>1010</span> Bot management");
262 let response = fixture.response();
263 let mut fingerprint = StubFingerprint::new();
264 let mut tls = StubTlsManager::new();
265 let recorder = StubRecorder::new();
266 let handler = BotManagementHandler::new();
267 let plan = handler
268 .plan(
269 &response,
270 Some(&mut fingerprint),
271 Some(&mut tls),
272 Some(&recorder),
273 )
274 .expect("plan");
275 assert!(plan.should_retry);
276 assert_eq!(
277 plan.metadata.get("fingerprint_reset"),
278 Some(&"true".to_string())
279 );
280 assert_eq!(plan.metadata.get("tls_rotated"), Some(&"true".to_string()));
281 assert!(fingerprint.was_invalidated(fixture.domain()));
282 assert!(tls.was_rotated(fixture.domain()));
283 assert_eq!(recorder.count(), 1);
284 assert!(recorder.recorded(fixture.domain(), "cf_bot_management"));
285 }
286
287 #[test]
288 fn plan_handles_missing_aux_components() {
289 let fixture =
290 ResponseFixture::new("<span class='cf-error-code'>1010</span> Bot management");
291 let response = fixture.response();
292 let handler = BotManagementHandler::new();
293 let plan = handler.plan(&response, None, None, None).expect("plan");
294 assert_eq!(
295 plan.metadata.get("fingerprint_reset"),
296 Some(&"false".to_string())
297 );
298 assert_eq!(plan.metadata.get("tls_rotated"), Some(&"false".to_string()));
299 }
300}