cloudscraper_rs/challenges/core/
executor.rs

1//! Challenge submission execution utilities.
2//!
3//! Handles the end-to-end process of submitting the IUAM payload, honoring
4//! Cloudflare's required delay, following redirects, and surfacing meaningful
5//! errors back to the caller.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use http::Method;
13use http::header::{HeaderMap, HeaderName, HeaderValue, LOCATION, REFERER};
14use thiserror::Error;
15use tokio::time::sleep;
16use url::Url;
17
18use super::types::ChallengeSubmission;
19
20/// Contract that abstracts the underlying HTTP transport used during challenge replay.
21///
22/// Implementations should ensure that cookies and other stateful data are
23/// preserved between calls so the session behaves consistently.
24#[async_trait]
25pub trait ChallengeHttpClient: Send + Sync {
26    async fn send_form(
27        &self,
28        method: &Method,
29        url: &Url,
30        headers: &HeaderMap,
31        form_fields: &HashMap<String, String>,
32        allow_redirects: bool,
33    ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError>;
34
35    async fn send_with_body(
36        &self,
37        method: &Method,
38        url: &Url,
39        headers: &HeaderMap,
40        body: Option<&[u8]>,
41        allow_redirects: bool,
42    ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError>;
43}
44
45/// Minimal response representation returned by the transport abstraction.
46#[derive(Debug, Clone)]
47pub struct ChallengeHttpResponse {
48    pub status: u16,
49    pub headers: HeaderMap,
50    pub body: Vec<u8>,
51    pub url: Url,
52    pub is_redirect: bool,
53}
54
55impl ChallengeHttpResponse {
56    pub fn location(&self) -> Option<&str> {
57        self.headers
58            .get(LOCATION)
59            .and_then(|value| value.to_str().ok())
60    }
61}
62
63#[derive(Debug, Error)]
64pub enum ChallengeHttpClientError {
65    #[error("http transport error: {0}")]
66    Transport(String),
67}
68
69/// Failure states that can occur while executing the Cloudflare challenge flow.
70#[derive(Debug, Error)]
71pub enum ChallengeExecutionError {
72    #[error("failed to convert header '{0}'")]
73    InvalidHeader(String),
74    #[error("invalid challenge answer detected")]
75    InvalidAnswer,
76    #[error("http client error: {0}")]
77    Client(#[from] ChallengeHttpClientError),
78}
79
80/// Context about the original request that triggered the challenge.
81#[derive(Debug, Clone)]
82pub struct OriginalRequest {
83    pub method: Method,
84    pub url: Url,
85    pub headers: HeaderMap,
86    pub body: Option<Vec<u8>>,
87}
88
89impl OriginalRequest {
90    pub fn new(method: Method, url: Url) -> Self {
91        Self {
92            method,
93            url,
94            headers: HeaderMap::new(),
95            body: None,
96        }
97    }
98
99    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
100        self.headers = headers;
101        self
102    }
103
104    pub fn with_body(mut self, body: Option<Vec<u8>>) -> Self {
105        self.body = body;
106        self
107    }
108}
109
110/// Executes the Cloudflare response submission for IUAM-style challenges.
111///
112/// Submission steps:
113/// 1. Wait the enforced delay duration.
114/// 2. POST the computed payload back to Cloudflare.
115/// 3. If the response is a redirect, follow it manually (respecting relative URLs).
116/// 4. Return the final response so callers can resume normal processing.
117pub async fn execute_challenge_submission(
118    client: Arc<dyn ChallengeHttpClient>,
119    submission: ChallengeSubmission,
120    original_request: OriginalRequest,
121) -> Result<ChallengeHttpResponse, ChallengeExecutionError> {
122    if submission.wait > Duration::from_millis(0) {
123        sleep(submission.wait).await;
124    }
125
126    let submission_headers = convert_headers(&submission.headers)?;
127    let first_response = client
128        .send_form(
129            &submission.method,
130            &submission.url,
131            &submission_headers,
132            &submission.form_fields,
133            submission.allow_redirects,
134        )
135        .await?;
136
137    if first_response.status == 400 {
138        return Err(ChallengeExecutionError::InvalidAnswer);
139    }
140
141    if !first_response.is_redirect {
142        return Ok(first_response);
143    }
144
145    let redirect_target = resolve_redirect(&first_response, &original_request.url);
146    let mut follow_headers = original_request.headers.clone();
147    follow_headers.insert(
148        REFERER,
149        HeaderValue::from_str(first_response.url.as_str())
150            .map_err(|_| ChallengeExecutionError::InvalidHeader("referer".into()))?,
151    );
152
153    let follow_response = client
154        .send_with_body(
155            &original_request.method,
156            &redirect_target,
157            &follow_headers,
158            original_request.body.as_deref(),
159            true,
160        )
161        .await?;
162
163    Ok(follow_response)
164}
165
166fn convert_headers(
167    headers: &HashMap<String, String>,
168) -> Result<HeaderMap, ChallengeExecutionError> {
169    let mut map = HeaderMap::new();
170    for (name, value) in headers {
171        let header_name = HeaderName::from_bytes(name.as_bytes())
172            .map_err(|_| ChallengeExecutionError::InvalidHeader(name.clone()))?;
173        let header_value = HeaderValue::from_str(value)
174            .map_err(|_| ChallengeExecutionError::InvalidHeader(name.clone()))?;
175        map.insert(header_name, header_value);
176    }
177    Ok(map)
178}
179
180fn resolve_redirect(first_response: &ChallengeHttpResponse, original_url: &Url) -> Url {
181    if let Some(location) = first_response.location() {
182        if let Ok(absolute) = Url::parse(location)
183            && absolute.has_host()
184        {
185            return absolute;
186        }
187
188        if let Ok(joined) = first_response.url.join(location) {
189            return joined;
190        }
191    }
192
193    original_url.clone()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::sync::Mutex;
200
201    struct StubClient {
202        responses: Mutex<Vec<ChallengeHttpResponse>>,
203    }
204
205    impl StubClient {
206        fn new(responses: Vec<ChallengeHttpResponse>) -> Self {
207            Self {
208                responses: Mutex::new(responses.into_iter().rev().collect()),
209            }
210        }
211
212        fn pop_response(&self) -> ChallengeHttpResponse {
213            self.responses
214                .lock()
215                .unwrap()
216                .pop()
217                .expect("no more stub responses")
218        }
219    }
220
221    #[async_trait]
222    impl ChallengeHttpClient for StubClient {
223        async fn send_form(
224            &self,
225            _method: &Method,
226            _url: &Url,
227            _headers: &HeaderMap,
228            _form_fields: &HashMap<String, String>,
229            _allow_redirects: bool,
230        ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError> {
231            Ok(self.pop_response())
232        }
233
234        async fn send_with_body(
235            &self,
236            _method: &Method,
237            _url: &Url,
238            _headers: &HeaderMap,
239            _body: Option<&[u8]>,
240            _allow_redirects: bool,
241        ) -> Result<ChallengeHttpResponse, ChallengeHttpClientError> {
242            Ok(self.pop_response())
243        }
244    }
245
246    fn make_response(status: u16, url: &str, headers: HeaderMap) -> ChallengeHttpResponse {
247        ChallengeHttpResponse {
248            status,
249            headers,
250            body: vec![],
251            url: Url::parse(url).unwrap(),
252            is_redirect: (300..400).contains(&status),
253        }
254    }
255
256    #[tokio::test]
257    async fn returns_first_response_when_not_redirect() {
258        let submission = ChallengeSubmission::new(
259            Method::POST,
260            Url::parse("https://example.com/submit").unwrap(),
261            HashMap::from([(String::from("foo"), String::from("bar"))]),
262            HashMap::from([(String::from("referer"), String::from("https://example.com"))]),
263            Duration::from_millis(0),
264        );
265
266        let original =
267            OriginalRequest::new(Method::GET, Url::parse("https://example.com").unwrap());
268
269        let headers = HeaderMap::new();
270        let client = Arc::new(StubClient::new(vec![make_response(
271            200,
272            "https://example.com",
273            headers.clone(),
274        )]));
275
276        let response = execute_challenge_submission(client, submission, original)
277            .await
278            .unwrap();
279
280        assert_eq!(response.status, 200);
281    }
282
283    #[tokio::test]
284    async fn follows_redirect_and_returns_final_response() {
285        let submission = ChallengeSubmission::new(
286            Method::POST,
287            Url::parse("https://example.com/submit").unwrap(),
288            HashMap::from([(String::from("foo"), String::from("bar"))]),
289            HashMap::from([(String::from("referer"), String::from("https://example.com"))]),
290            Duration::from_millis(0),
291        );
292
293        let mut original_headers = HeaderMap::new();
294        original_headers.insert("user-agent", HeaderValue::from_static("test-agent"));
295
296        let original = OriginalRequest::new(
297            Method::GET,
298            Url::parse("https://example.com/protected").unwrap(),
299        )
300        .with_headers(original_headers.clone());
301
302        let mut redirect_headers = HeaderMap::new();
303        redirect_headers.insert(LOCATION, HeaderValue::from_static("/redirected"));
304
305        let client = Arc::new(StubClient::new(vec![
306            make_response(200, "https://example.com/redirected", HeaderMap::new()),
307            make_response(302, "https://example.com/submit", redirect_headers),
308        ]));
309
310        let response = execute_challenge_submission(client, submission, original)
311            .await
312            .unwrap();
313
314        assert_eq!(response.url.as_str(), "https://example.com/redirected");
315    }
316}