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