cloudscraper_rs/challenges/solvers/
access_denied.rs

1//! Handler for Cloudflare Access Denied (code 1020) responses.
2//!
3//! Recommends mitigation steps such as proxy rotation and adaptive backoff
4//! when Access Denied pages appear instead of solvable forms.
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::{ChallengeSolver, MitigationPlan};
16
17const DEFAULT_DELAY_MIN_SECS: f32 = 5.0;
18const DEFAULT_DELAY_MAX_SECS: f32 = 15.0;
19
20/// Computes mitigation steps for Access Denied (1020) responses.
21pub struct AccessDeniedHandler {
22    delay_min: Duration,
23    delay_max: Duration,
24}
25
26impl AccessDeniedHandler {
27    pub fn new() -> Self {
28        Self {
29            delay_min: Duration::from_secs_f32(DEFAULT_DELAY_MIN_SECS),
30            delay_max: Duration::from_secs_f32(DEFAULT_DELAY_MAX_SECS),
31        }
32    }
33
34    /// Override the random delay range applied before retrying with a new proxy.
35    pub fn with_delay_range(mut self, min: Duration, max: Duration) -> Self {
36        self.delay_min = min;
37        self.delay_max = if max < min { min } else { max };
38        self
39    }
40
41    /// Returns true if the response matches the Access Denied signature.
42    pub fn is_access_denied(response: &ChallengeResponse<'_>) -> bool {
43        is_cloudflare_response(response)
44            && response.status == 403
45            && ACCESS_DENIED_RE.is_match(response.body)
46    }
47
48    /// Build a mitigation plan for Access Denied responses.
49    pub fn plan(
50        &self,
51        response: &ChallengeResponse<'_>,
52        proxy_pool: Option<&mut dyn ProxyPool>,
53        current_proxy: Option<&str>,
54    ) -> Result<MitigationPlan, AccessDeniedError> {
55        if !Self::is_access_denied(response) {
56            return Err(AccessDeniedError::NotAccessDenied);
57        }
58
59        let delay = self.random_delay();
60        let mut plan = MitigationPlan::retry_after(delay, "access_denied");
61        plan.metadata.insert("trigger".into(), "cf_1020".into());
62
63        match proxy_pool {
64            Some(pool) => {
65                if let Some(proxy) = current_proxy {
66                    pool.report_failure(proxy);
67                    plan.metadata
68                        .insert("previous_proxy".into(), proxy.to_string());
69                }
70
71                if let Some(next_proxy) = pool.next_proxy() {
72                    plan = plan.with_proxy(next_proxy.clone());
73                    plan.metadata
74                        .insert("proxy_rotation".into(), "success".into());
75                } else {
76                    plan.should_retry = false;
77                    plan.reason = "access_denied_no_proxy".into();
78                    plan.metadata
79                        .insert("proxy_rotation".into(), "unavailable".into());
80                }
81            }
82            None => {
83                plan.should_retry = false;
84                plan.reason = "access_denied_no_proxy".into();
85                plan.metadata
86                    .insert("proxy_rotation".into(), "not_configured".into());
87            }
88        }
89
90        Ok(plan)
91    }
92
93    fn random_delay(&self) -> Duration {
94        if self.delay_max <= self.delay_min {
95            return self.delay_min;
96        }
97        let mut rng = rand::thread_rng();
98        let min = self.delay_min.as_secs_f32();
99        let max = self.delay_max.as_secs_f32();
100        Duration::from_secs_f32(rng.gen_range(min..max))
101    }
102}
103
104impl Default for AccessDeniedHandler {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl ChallengeSolver for AccessDeniedHandler {
111    fn name(&self) -> &'static str {
112        "access_denied"
113    }
114}
115
116/// Trait representing a proxy rotation pool.
117pub trait ProxyPool {
118    fn report_failure(&mut self, proxy: &str);
119    fn next_proxy(&mut self) -> Option<String>;
120}
121
122#[derive(Debug, Error)]
123pub enum AccessDeniedError {
124    #[error("response is not a Cloudflare access denied challenge")]
125    NotAccessDenied,
126}
127
128static ACCESS_DENIED_RE: Lazy<Regex> = Lazy::new(|| {
129    RegexBuilder::new(
130        r#"(<span[^>]*class=['"]cf-error-code['"]>1020<|Access denied|banned your access)"#,
131    )
132    .case_insensitive(true)
133    .dot_matches_new_line(true)
134    .build()
135    .expect("invalid access denied regex")
136});
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use http::{HeaderMap, Method, header::SERVER};
142    use url::Url;
143
144    struct ResponseFixture {
145        url: Url,
146        headers: HeaderMap,
147        method: Method,
148        body: String,
149    }
150
151    impl ResponseFixture {
152        fn new(body: &str) -> Self {
153            let mut headers = HeaderMap::new();
154            headers.insert(SERVER, "cloudflare".parse().unwrap());
155            Self {
156                url: Url::parse("https://example.com/protected").unwrap(),
157                headers,
158                method: Method::GET,
159                body: body.to_string(),
160            }
161        }
162
163        fn response(&self) -> ChallengeResponse<'_> {
164            ChallengeResponse {
165                url: &self.url,
166                status: 403,
167                headers: &self.headers,
168                body: &self.body,
169                request_method: &self.method,
170            }
171        }
172    }
173
174    struct StubProxyPool {
175        proxies: Vec<String>,
176        reported: Vec<String>,
177    }
178
179    impl StubProxyPool {
180        fn new(proxies: &[&str]) -> Self {
181            Self {
182                proxies: proxies.iter().map(|p| p.to_string()).collect(),
183                reported: Vec::new(),
184            }
185        }
186    }
187
188    impl ProxyPool for StubProxyPool {
189        fn report_failure(&mut self, proxy: &str) {
190            self.reported.push(proxy.to_string());
191        }
192
193        fn next_proxy(&mut self) -> Option<String> {
194            self.proxies.pop()
195        }
196    }
197
198    #[test]
199    fn detects_access_denied() {
200        let fixture = ResponseFixture::new("<span class='cf-error-code'>1020</span> Access denied");
201        let response = fixture.response();
202        assert!(AccessDeniedHandler::is_access_denied(&response));
203    }
204
205    #[test]
206    fn plan_rotates_proxy_when_available() {
207        let fixture = ResponseFixture::new("<span class='cf-error-code'>1020</span> Access denied");
208        let response = fixture.response();
209        let mut pool = StubProxyPool::new(&["http://1.1.1.1:8080", "http://2.2.2.2:8080"]);
210        let handler = AccessDeniedHandler::new();
211        let plan = handler
212            .plan(&response, Some(&mut pool), Some("http://1.1.1.1:8080"))
213            .expect("plan");
214        assert!(plan.should_retry);
215        assert!(plan.new_proxy.is_some());
216        assert_eq!(
217            plan.metadata.get("proxy_rotation"),
218            Some(&"success".to_string())
219        );
220    }
221
222    #[test]
223    fn plan_disables_retry_without_proxy_manager() {
224        let fixture = ResponseFixture::new("<span class='cf-error-code'>1020</span> Access denied");
225        let response = fixture.response();
226        let handler = AccessDeniedHandler::new();
227        let plan = handler.plan(&response, None, None).expect("plan");
228        assert!(!plan.should_retry);
229        assert_eq!(
230            plan.metadata.get("proxy_rotation"),
231            Some(&"not_configured".to_string())
232        );
233    }
234}