cloudscraper_rs/challenges/solvers/
bot_management.rs

1//! Handler for Cloudflare Bot Management detections.
2//!
3//! Triggers advanced evasion tactics such as fingerprint resets and TLS
4//! rotation when Bot Management blocks are detected.
5
6use 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
22/// Plans mitigation steps for Bot Management blocks (1010).
23pub 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}