cloudscraper_rs/challenges/solvers/
access_denied.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::{ChallengeSolver, MitigationPlan};
16
17const DEFAULT_DELAY_MIN_SECS: f32 = 5.0;
18const DEFAULT_DELAY_MAX_SECS: f32 = 15.0;
19
20pub 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 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 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 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
116pub 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}