cloudscraper_rs/challenges/solvers/
javascript_v1.rs

1//! Solver for Cloudflare IUAM / JavaScript challenge v1.
2//!
3//! Parses the challenge page, evaluates the embedded JavaScript snippet via the
4//! provided interpreter, and produces the submission payload the caller must
5//! POST back to Cloudflare.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use once_cell::sync::Lazy;
11use regex::{Regex, RegexBuilder};
12use thiserror::Error;
13
14use crate::challenges::core::{
15    ChallengeExecutionError, ChallengeHttpClient, ChallengeHttpResponse, ChallengeParseError,
16    ChallengeResponse, ChallengeSubmission, OriginalRequest, execute_challenge_submission,
17    is_cloudflare_response, origin_from_url, parse_iuam_challenge,
18};
19use crate::external_deps::interpreters::{InterpreterError, JavascriptInterpreter};
20
21use super::ChallengeSolver;
22
23/// Solver for IUAM (v1) challenges.
24pub struct JavascriptV1Solver {
25    interpreter: Arc<dyn JavascriptInterpreter>,
26}
27
28impl JavascriptV1Solver {
29    pub fn new(interpreter: Arc<dyn JavascriptInterpreter>) -> Self {
30        Self { interpreter }
31    }
32
33    /// Returns `true` if the response resembles a Cloudflare IUAM challenge.
34    pub fn is_iuam_challenge(&self, response: &ChallengeResponse<'_>) -> bool {
35        is_cloudflare_response(response)
36            && matches!(response.status, 429 | 503)
37            && response.body.contains("/cdn-cgi/images/trace/jsch/")
38            && parse_iuam_challenge(response).is_ok()
39    }
40
41    /// Returns `true` if Cloudflare responded with a captcha challenge.
42    pub fn is_captcha_challenge(&self, response: &ChallengeResponse<'_>) -> bool {
43        is_cloudflare_response(response)
44            && response.status == 403
45            && response.body.contains("__cf_chl_captcha_tk__")
46            && response.body.contains("data-sitekey")
47    }
48
49    /// Returns `true` when Cloudflare blocked the request (1020 firewall).
50    pub fn is_firewall_blocked(&self, response: &ChallengeResponse<'_>) -> bool {
51        is_cloudflare_response(response)
52            && response.status == 403
53            && response
54                .body
55                .to_ascii_lowercase()
56                .contains("<span class=\"cf-error-code\">1020</span>")
57    }
58
59    /// Parse the IUAM page and return the ready-to-submit payload.
60    pub fn solve(
61        &self,
62        response: &ChallengeResponse<'_>,
63    ) -> Result<ChallengeSubmission, JavascriptV1Error> {
64        if !self.is_iuam_challenge(response) {
65            return Err(JavascriptV1Error::NotAnIuamChallenge);
66        }
67
68        let base_url = response.url.clone();
69        let host = base_url.host_str().ok_or(JavascriptV1Error::MissingHost)?;
70
71        let blueprint = parse_iuam_challenge(response).map_err(JavascriptV1Error::Parse)?;
72
73        let answer = self
74            .interpreter
75            .solve_challenge(response.body, host)
76            .map_err(JavascriptV1Error::Interpreter)?;
77
78        let mut submission = blueprint
79            .to_submission(&base_url, vec![("jschl_answer".to_string(), answer)])
80            .map_err(JavascriptV1Error::Parse)?;
81
82        submission.wait = extract_delay(response.body)?;
83        submission
84            .headers
85            .insert("Referer".into(), response.url.as_str().to_string());
86        submission
87            .headers
88            .insert("Origin".into(), origin_from_url(&base_url));
89
90        Ok(submission)
91    }
92
93    /// Solve the challenge and immediately submit the response through the provided client.
94    pub async fn solve_and_submit(
95        &self,
96        client: Arc<dyn ChallengeHttpClient>,
97        response: &ChallengeResponse<'_>,
98        original_request: OriginalRequest,
99    ) -> Result<ChallengeHttpResponse, JavascriptV1Error> {
100        let submission = self.solve(response)?;
101        execute_challenge_submission(client, submission, original_request)
102            .await
103            .map_err(JavascriptV1Error::Submission)
104    }
105}
106
107impl ChallengeSolver for JavascriptV1Solver {
108    fn name(&self) -> &'static str {
109        "javascript_v1"
110    }
111}
112
113fn extract_delay(body: &str) -> Result<Duration, JavascriptV1Error> {
114    static DELAY_RE: Lazy<Regex> = Lazy::new(|| {
115        RegexBuilder::new(r#"submit\(\);\r?\n\s*},\s*([0-9]+)"#)
116            .case_insensitive(true)
117            .build()
118            .unwrap()
119    });
120
121    let captures = DELAY_RE
122        .captures(body)
123        .ok_or(JavascriptV1Error::DelayNotFound)?;
124
125    let millis = captures
126        .get(1)
127        .and_then(|m| m.as_str().parse::<u64>().ok())
128        .ok_or(JavascriptV1Error::DelayNotFound)?;
129
130    Ok(Duration::from_millis(millis))
131}
132
133/// IUAM solver errors.
134#[derive(Debug, Error)]
135pub enum JavascriptV1Error {
136    #[error("response is not an IUAM challenge")]
137    NotAnIuamChallenge,
138    #[error("unable to determine challenge host")]
139    MissingHost,
140    #[error("missing Cloudflare delay value")]
141    DelayNotFound,
142    #[error("javascript interpreter error: {0}")]
143    Interpreter(InterpreterError),
144    #[error("challenge parsing error: {0}")]
145    Parse(ChallengeParseError),
146    #[error("challenge submission failed: {0}")]
147    Submission(ChallengeExecutionError),
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::challenges::core::ChallengeHttpClientError;
154    use async_trait::async_trait;
155    use http::{HeaderMap, Method, header::SERVER};
156    use std::sync::Mutex;
157    use url::Url;
158
159    struct StubInterpreter;
160
161    impl JavascriptInterpreter for StubInterpreter {
162        fn solve_challenge(
163            &self,
164            _page_html: &str,
165            _host: &str,
166        ) -> Result<String, InterpreterError> {
167            Ok("42".into())
168        }
169    }
170
171    struct ResponseFixture {
172        url: Url,
173        headers: HeaderMap,
174        method: Method,
175        body: String,
176        status: u16,
177    }
178
179    impl ResponseFixture {
180        fn new(body: &str, status: u16) -> Self {
181            let mut headers = HeaderMap::new();
182            headers.insert(SERVER, "cloudflare".parse().unwrap());
183            Self {
184                url: Url::parse("https://example.com/").unwrap(),
185                headers,
186                method: Method::GET,
187                body: body.to_string(),
188                status,
189            }
190        }
191
192        fn response(&self) -> ChallengeResponse<'_> {
193            ChallengeResponse {
194                url: &self.url,
195                status: self.status,
196                headers: &self.headers,
197                body: &self.body,
198                request_method: &self.method,
199            }
200        }
201
202        fn url(&self) -> &Url {
203            &self.url
204        }
205    }
206
207    #[test]
208    fn solve_extracts_payload() {
209        let html = r#"
210            <html>
211              <body>
212                <form id='challenge-form' action='/cdn-cgi/l/chk_jschl?__cf_chl_f_tk=foo' method='POST'>
213                  <input type='hidden' name='r' value='abc'/>
214                  <input type='hidden' name='jschl_vc' value='def'/>
215                  <input type='hidden' name='pass' value='ghi'/>
216                </form>
217                <script>setTimeout(function(){ submit();
218                }, 4000);</script>
219                <script src='/cdn-cgi/images/trace/jsch/'></script>
220              </body>
221            </html>
222        "#;
223
224        let solver = JavascriptV1Solver::new(Arc::new(StubInterpreter));
225        let fixture = ResponseFixture::new(html, 503);
226        let resp = fixture.response();
227        assert!(solver.is_iuam_challenge(&resp));
228        let submission = solver.solve(&resp).unwrap();
229        assert_eq!(submission.method, Method::POST);
230        assert_eq!(
231            submission.form_fields.get("jschl_answer"),
232            Some(&"42".to_string())
233        );
234        assert_eq!(submission.wait, Duration::from_millis(4000));
235    }
236
237    struct StubClient {
238        responses: Mutex<Vec<ChallengeHttpResponse>>,
239    }
240
241    impl StubClient {
242        fn new(responses: Vec<ChallengeHttpResponse>) -> Self {
243            Self {
244                responses: Mutex::new(responses.into_iter().rev().collect()),
245            }
246        }
247
248        fn pop_response(&self) -> ChallengeHttpResponse {
249            self.responses
250                .lock()
251                .unwrap()
252                .pop()
253                .expect("no more stub responses")
254        }
255    }
256
257    #[async_trait]
258    impl ChallengeHttpClient for StubClient {
259        async fn send_form(
260            &self,
261            _method: &Method,
262            _url: &Url,
263            _headers: &http::HeaderMap,
264            _form_fields: &std::collections::HashMap<String, String>,
265            _allow_redirects: bool,
266        ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError> {
267            Ok(self.pop_response())
268        }
269
270        async fn send_with_body(
271            &self,
272            _method: &Method,
273            _url: &Url,
274            _headers: &http::HeaderMap,
275            _body: Option<&[u8]>,
276            _allow_redirects: bool,
277        ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError> {
278            Ok(self.pop_response())
279        }
280    }
281
282    #[tokio::test]
283    async fn solve_and_submit_executes_challenge() {
284        let solver = JavascriptV1Solver::new(Arc::new(StubInterpreter));
285        let html = r#"
286            <html>
287              <body>
288                <form id='challenge-form' action='/cdn-cgi/l/chk_jschl?__cf_chl_f_tk=foo' method='POST'>
289                  <input type='hidden' name='r' value='abc'/>
290                  <input type='hidden' name='jschl_vc' value='def'/>
291                  <input type='hidden' name='pass' value='ghi'/>
292                </form>
293                <script>setTimeout(function(){ submit();
294                }, 0);</script>
295                <script src='/cdn-cgi/images/trace/jsch/'></script>
296              </body>
297            </html>
298        "#;
299        let fixture = ResponseFixture::new(html, 503);
300        let response = fixture.response();
301        let original = OriginalRequest::new(Method::GET, fixture.url().clone());
302
303        let client = Arc::new(StubClient::new(vec![ChallengeHttpResponse {
304            status: 200,
305            headers: HeaderMap::new(),
306            body: Vec::new(),
307            url: Url::parse("https://example.com/success").unwrap(),
308            is_redirect: false,
309        }]));
310
311        let result = solver
312            .solve_and_submit(client, &response, original)
313            .await
314            .unwrap();
315
316        assert_eq!(result.status, 200);
317    }
318}