Skip to main content

captchaforge/solver/
token_oracle.rs

1//! Token-validation oracle.
2//!
3//! [`super::oracle`] proves the *page* advanced past the challenge.
4//! This proves the *token* actually unlocks the protected resource:
5//! we POST it back to a configured verify endpoint and assert the
6//! response is non-blocked.
7//!
8//! Without this layer, "we got a Turnstile token" can mean any of:
9//!
10//! - Real token, server happily accepts it ✅
11//! - Real token, but server-side IP reputation rejects regardless ❌
12//! - Real token, but vendor short-lived expiry already elapsed ❌
13//! - Demo-sitekey token (`1x00000000000000000000AA`), useless on
14//!   production ❌
15//! - Token harvested from a non-matching origin (vendor binds tokens
16//!   to the page that requested them) ❌
17//!
18//! The end-to-end oracle catches all five.
19//!
20//! # Configuration
21//!
22//! Opt-in. The chain doesn't validate by default, most use cases
23//! don't have a verify endpoint to call. Use [`TokenValidator`]
24//! manually after a chain solve when you want the proof:
25//!
26//! ```no_run
27//! # async fn run(token: &str) -> anyhow::Result<()> {
28//! use captchaforge::solver::token_oracle::{TokenValidator, ValidationVerdict};
29//!
30//! let validator = TokenValidator::new("https://target.example/verify-captcha");
31//! let verdict = validator.validate(token).await?;
32//! assert_eq!(verdict, ValidationVerdict::Accepted);
33//! # Ok(()) }
34//! ```
35//!
36//! # Verdicts
37//!
38//! - [`ValidationVerdict::Accepted`], endpoint returned 2xx with
39//!   no block-phrase markers in the body.
40//! - [`ValidationVerdict::Rejected`], endpoint returned 4xx/5xx
41//!   OR a 2xx with block-phrase markers.
42//! - [`ValidationVerdict::Inconclusive`], network error,
43//!   timeout, or unparseable response. Don't treat as accepted.
44
45use anyhow::Result;
46use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
47use serde::{Deserialize, Serialize};
48use std::time::Duration;
49
50use crate::solver::oracle::BLOCK_PHRASES;
51
52/// Verdict from a token-replay attempt.
53///
54/// `#[non_exhaustive]` so we can add (e.g.) `RateLimited` without
55/// breaking downstream `match`es.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum ValidationVerdict {
60    Accepted,
61    Rejected,
62    Inconclusive,
63}
64
65/// HTTP method for the validation request.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ValidatorMethod {
68    Post,
69    Get,
70}
71
72/// How the token is sent. Most vendor-side endpoints expect POSTed
73/// form data with `cf-turnstile-response`/`g-recaptcha-response`/
74/// `h-captcha-response` keys. Origin-side endpoints often want
75/// JSON. Both supported.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TokenEncoding {
78    /// `application/x-www-form-urlencoded`: `field=token`.
79    FormUrlEncoded,
80    /// `application/json`: `{"field": "token"}`.
81    Json,
82    /// HTTP header: `Authorization: Bearer <token>`. Useful when
83    /// the verify endpoint authenticates with the token directly
84    /// rather than a captcha-response field.
85    BearerHeader,
86}
87
88/// Configuration for [`TokenValidator`].
89#[derive(Debug, Clone)]
90pub struct TokenValidator {
91    pub endpoint: String,
92    pub method: ValidatorMethod,
93    pub encoding: TokenEncoding,
94    /// Form/JSON field name for the token. Default `cf-turnstile-response`.
95    pub field: String,
96    /// Per-request timeout. Default 10s.
97    pub timeout: Duration,
98}
99
100struct ValidationRequest {
101    method: &'static str,
102    url: String,
103    headers: Vec<(String, String)>,
104    body: Option<Vec<u8>>,
105}
106
107impl TokenValidator {
108    /// Construct a validator that POSTs the token as
109    /// `cf-turnstile-response=<token>` to `endpoint`.
110    pub fn new(endpoint: impl Into<String>) -> Self {
111        Self {
112            endpoint: endpoint.into(),
113            method: ValidatorMethod::Post,
114            encoding: TokenEncoding::FormUrlEncoded,
115            field: "cf-turnstile-response".into(),
116            timeout: Duration::from_secs(10),
117        }
118    }
119
120    pub fn with_field(mut self, field: impl Into<String>) -> Self {
121        self.field = field.into();
122        self
123    }
124
125    pub fn with_encoding(mut self, encoding: TokenEncoding) -> Self {
126        self.encoding = encoding;
127        self
128    }
129
130    pub fn with_method(mut self, method: ValidatorMethod) -> Self {
131        self.method = method;
132        self
133    }
134
135    pub fn with_timeout(mut self, timeout: Duration) -> Self {
136        self.timeout = timeout;
137        self
138    }
139
140    /// Send the token to the endpoint and classify the response.
141    ///
142    /// `Inconclusive` on network/timeout error rather than `Err` 
143    /// the caller almost always wants to merge the verdict into a
144    /// pipeline that already has its own error handling, and a
145    /// network blip on the verify endpoint is not the same kind of
146    /// failure as a malformed config.
147    pub async fn validate(&self, token: &str) -> Result<ValidationVerdict> {
148        let Some(request) = self.validation_request(token) else {
149            return Ok(ValidationVerdict::Inconclusive);
150        };
151
152        #[cfg(feature = "tls-impersonate")]
153        {
154            return match crate::waf_gate::send_validation(
155                request.method,
156                &request.url,
157                request.headers,
158                request.body,
159                self.timeout,
160            )
161            .await
162            {
163                Ok((status, body)) => Ok(classify_response(status, body.as_deref())),
164                Err(()) => Ok(ValidationVerdict::Inconclusive),
165            };
166        }
167
168        #[cfg(not(feature = "tls-impersonate"))]
169        {
170            let client = crate::http_client::timed_client(self.timeout)?;
171
172            let mut req = match request.method {
173                "POST" => client.post(&request.url),
174                "GET" => client.get(&request.url),
175                _ => return Ok(ValidationVerdict::Inconclusive),
176            };
177            for (name, value) in request.headers {
178                req = req.header(name, value);
179            }
180            if let Some(body) = request.body {
181                req = req.body(body);
182            }
183
184            let response = match req.send().await {
185                Ok(r) => r,
186                Err(_) => return Ok(ValidationVerdict::Inconclusive),
187            };
188
189            let status = response.status().as_u16();
190            // Bound the body read to MAX_VALIDATION_BODY_BYTES. A hostile
191            // or buggy verify endpoint streaming an unbounded payload would
192            // otherwise allocate a String of arbitrary size and OOM the
193            // worker. We only need the first ~64 KiB for block-phrase
194            // detection, that's well past every realistic verify-endpoint
195            // response body.
196            let body = read_capped_body(response, MAX_VALIDATION_BODY_BYTES).await;
197            Ok(classify_response(status, body.as_deref()))
198        }
199    }
200
201    fn validation_request(&self, token: &str) -> Option<ValidationRequest> {
202        let mut url = reqwest::Url::parse(&self.endpoint).ok()?;
203        let method = match self.method {
204            ValidatorMethod::Post => "POST",
205            ValidatorMethod::Get => "GET",
206        };
207        let mut headers = validation_default_header_pairs();
208        let mut body = None;
209
210        match (self.method, self.encoding) {
211            (ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => {
212                headers.push((
213                    CONTENT_TYPE.as_str().to_string(),
214                    "application/x-www-form-urlencoded".to_string(),
215                ));
216                body = Some(
217                    url::form_urlencoded::Serializer::new(String::new())
218                        .append_pair(&self.field, token)
219                        .finish()
220                        .into_bytes(),
221                );
222            }
223            (ValidatorMethod::Post, TokenEncoding::Json) => {
224                headers.push((
225                    CONTENT_TYPE.as_str().to_string(),
226                    "application/json".to_string(),
227                ));
228                body = Some(
229                    serde_json::to_vec(&serde_json::json!({
230                        self.field.as_str(): token
231                    }))
232                    .ok()?,
233                );
234            }
235            (ValidatorMethod::Post, TokenEncoding::BearerHeader) => {
236                headers.push(authorization_header_pair(token)?);
237            }
238            (ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => {
239                url.query_pairs_mut().append_pair(&self.field, token);
240            }
241            (ValidatorMethod::Get, TokenEncoding::Json) => {
242                headers.push(("X-Token".to_string(), header_value_string(token)?));
243            }
244            (ValidatorMethod::Get, TokenEncoding::BearerHeader) => {
245                headers.push(authorization_header_pair(token)?);
246            }
247        }
248
249        Some(ValidationRequest {
250            method,
251            url: url.to_string(),
252            headers,
253            body,
254        })
255    }
256}
257
258/// Maximum number of bytes to read from a verify-endpoint response
259/// before truncating. Block-phrase detection only needs the first
260/// few KiB; this cap is generous to leave room for verbose vendor
261/// error JSON without ever allowing an unbounded read.
262///
263/// Consumed only by the reqwest path of [`TokenValidator::validate`]; the
264/// `tls-impersonate` path caps inside `waf_gate::send_validation` via the same
265/// [`crate::waf_gate::VALIDATION_BODY_CAP`], so this alias is gated to match its
266/// sole consumer.
267#[cfg(not(feature = "tls-impersonate"))]
268const MAX_VALIDATION_BODY_BYTES: usize = crate::waf_gate::VALIDATION_BODY_CAP;
269
270fn validation_default_header_pairs() -> Vec<(String, String)> {
271    guise::http::default_browser_request_headers_without_compression(
272        guise::http::BrowserRequestKind::SameOriginFetch,
273    )
274    .into_iter()
275    .map(|header| (header.name.to_string(), header.value))
276    .collect()
277}
278
279fn authorization_header_pair(token: &str) -> Option<(String, String)> {
280    Some((
281        AUTHORIZATION.as_str().to_string(),
282        header_value_string(&format!("Bearer {token}"))?,
283    ))
284}
285
286fn header_value_string(raw: &str) -> Option<String> {
287    let value = HeaderValue::from_str(raw).ok()?;
288    value.to_str().ok().map(str::to_string)
289}
290
291// Bounded body reader for the reqwest validation path only; the
292// `tls-impersonate` path reads+caps inside `waf_gate::send_validation`.
293#[cfg(not(feature = "tls-impersonate"))]
294async fn read_capped_body(mut response: reqwest::Response, max: usize) -> Option<String> {
295    // Use `Response::chunk()` rather than `bytes_stream()` so the
296    // crate doesn't need the `stream` feature of reqwest enabled.
297    let mut buf: Vec<u8> = Vec::new();
298    loop {
299        let chunk = response.chunk().await.ok().flatten();
300        match chunk {
301            Some(bytes) => {
302                if buf.len() + bytes.len() > max {
303                    let remaining = max.saturating_sub(buf.len());
304                    buf.extend_from_slice(&bytes[..remaining]);
305                    break;
306                }
307                buf.extend_from_slice(&bytes);
308            }
309            None => break,
310        }
311    }
312    // Truncate on a UTF-8 boundary before lossy-decoding so a block
313    // phrase straddling the cap doesn't get split mid-codepoint into
314    // an unrecognisable replacement character. Walk back at most 3
315    // bytes (that's the max width of a UTF-8 continuation tail).
316    let mut end = buf.len();
317    for _ in 0..3 {
318        if std::str::from_utf8(&buf[..end]).is_ok() {
319            break;
320        }
321        if end == 0 {
322            break;
323        }
324        end -= 1;
325    }
326    Some(String::from_utf8_lossy(&buf[..end]).into_owned())
327}
328
329/// Pure classifier, exposed so synthetic tests can exercise it
330/// without spinning up an HTTP server.
331pub fn classify_response(status: u16, body: Option<&str>) -> ValidationVerdict {
332    if !(200..300).contains(&status) {
333        return ValidationVerdict::Rejected;
334    }
335    if let Some(body) = body {
336        let lower = body.to_lowercase();
337        if BLOCK_PHRASES.iter().any(|p| lower.contains(p)) {
338            return ValidationVerdict::Rejected;
339        }
340    }
341    ValidationVerdict::Accepted
342}
343
344#[cfg(test)]
345#[path = "token_oracle/tests.rs"]
346mod tests;