captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Token-validation oracle.
//!
//! [`super::oracle`] proves the *page* advanced past the challenge.
//! This proves the *token* actually unlocks the protected resource:
//! we POST it back to a configured verify endpoint and assert the
//! response is non-blocked.
//!
//! Without this layer, "we got a Turnstile token" can mean any of:
//!
//! - Real token, server happily accepts it ✅
//! - Real token, but server-side IP reputation rejects regardless ❌
//! - Real token, but vendor short-lived expiry already elapsed ❌
//! - Demo-sitekey token (`1x00000000000000000000AA`), useless on
//!   production ❌
//! - Token harvested from a non-matching origin (vendor binds tokens
//!   to the page that requested them) ❌
//!
//! The end-to-end oracle catches all five.
//!
//! # Configuration
//!
//! Opt-in. The chain doesn't validate by default, most use cases
//! don't have a verify endpoint to call. Use [`TokenValidator`]
//! manually after a chain solve when you want the proof:
//!
//! ```no_run
//! # async fn run(token: &str) -> anyhow::Result<()> {
//! use captchaforge::solver::token_oracle::{TokenValidator, ValidationVerdict};
//!
//! let validator = TokenValidator::new("https://target.example/verify-captcha");
//! let verdict = validator.validate(token).await?;
//! assert_eq!(verdict, ValidationVerdict::Accepted);
//! # Ok(()) }
//! ```
//!
//! # Verdicts
//!
//! - [`ValidationVerdict::Accepted`], endpoint returned 2xx with
//!   no block-phrase markers in the body.
//! - [`ValidationVerdict::Rejected`], endpoint returned 4xx/5xx
//!   OR a 2xx with block-phrase markers.
//! - [`ValidationVerdict::Inconclusive`], network error,
//!   timeout, or unparseable response. Don't treat as accepted.

use anyhow::Result;
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use std::time::Duration;

use crate::solver::oracle::BLOCK_PHRASES;

/// Verdict from a token-replay attempt.
///
/// `#[non_exhaustive]` so we can add (e.g.) `RateLimited` without
/// breaking downstream `match`es.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValidationVerdict {
    Accepted,
    Rejected,
    Inconclusive,
}

/// HTTP method for the validation request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatorMethod {
    Post,
    Get,
}

/// How the token is sent. Most vendor-side endpoints expect POSTed
/// form data with `cf-turnstile-response`/`g-recaptcha-response`/
/// `h-captcha-response` keys. Origin-side endpoints often want
/// JSON. Both supported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenEncoding {
    /// `application/x-www-form-urlencoded`: `field=token`.
    FormUrlEncoded,
    /// `application/json`: `{"field": "token"}`.
    Json,
    /// HTTP header: `Authorization: Bearer <token>`. Useful when
    /// the verify endpoint authenticates with the token directly
    /// rather than a captcha-response field.
    BearerHeader,
}

/// Configuration for [`TokenValidator`].
#[derive(Debug, Clone)]
pub struct TokenValidator {
    pub endpoint: String,
    pub method: ValidatorMethod,
    pub encoding: TokenEncoding,
    /// Form/JSON field name for the token. Default `cf-turnstile-response`.
    pub field: String,
    /// Per-request timeout. Default 10s.
    pub timeout: Duration,
}

struct ValidationRequest {
    method: &'static str,
    url: String,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
}

impl TokenValidator {
    /// Construct a validator that POSTs the token as
    /// `cf-turnstile-response=<token>` to `endpoint`.
    pub fn new(endpoint: impl Into<String>) -> Self {
        Self {
            endpoint: endpoint.into(),
            method: ValidatorMethod::Post,
            encoding: TokenEncoding::FormUrlEncoded,
            field: "cf-turnstile-response".into(),
            timeout: Duration::from_secs(10),
        }
    }

    pub fn with_field(mut self, field: impl Into<String>) -> Self {
        self.field = field.into();
        self
    }

    pub fn with_encoding(mut self, encoding: TokenEncoding) -> Self {
        self.encoding = encoding;
        self
    }

    pub fn with_method(mut self, method: ValidatorMethod) -> Self {
        self.method = method;
        self
    }

    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Send the token to the endpoint and classify the response.
    ///
    /// `Inconclusive` on network/timeout error rather than `Err` 
    /// the caller almost always wants to merge the verdict into a
    /// pipeline that already has its own error handling, and a
    /// network blip on the verify endpoint is not the same kind of
    /// failure as a malformed config.
    pub async fn validate(&self, token: &str) -> Result<ValidationVerdict> {
        let Some(request) = self.validation_request(token) else {
            return Ok(ValidationVerdict::Inconclusive);
        };

        #[cfg(feature = "tls-impersonate")]
        {
            return match crate::waf_gate::send_validation(
                request.method,
                &request.url,
                request.headers,
                request.body,
                self.timeout,
            )
            .await
            {
                Ok((status, body)) => Ok(classify_response(status, body.as_deref())),
                Err(()) => Ok(ValidationVerdict::Inconclusive),
            };
        }

        #[cfg(not(feature = "tls-impersonate"))]
        {
            let client = crate::http_client::timed_client(self.timeout)?;

            let mut req = match request.method {
                "POST" => client.post(&request.url),
                "GET" => client.get(&request.url),
                _ => return Ok(ValidationVerdict::Inconclusive),
            };
            for (name, value) in request.headers {
                req = req.header(name, value);
            }
            if let Some(body) = request.body {
                req = req.body(body);
            }

            let response = match req.send().await {
                Ok(r) => r,
                Err(_) => return Ok(ValidationVerdict::Inconclusive),
            };

            let status = response.status().as_u16();
            // Bound the body read to MAX_VALIDATION_BODY_BYTES. A hostile
            // or buggy verify endpoint streaming an unbounded payload would
            // otherwise allocate a String of arbitrary size and OOM the
            // worker. We only need the first ~64 KiB for block-phrase
            // detection, that's well past every realistic verify-endpoint
            // response body.
            let body = read_capped_body(response, MAX_VALIDATION_BODY_BYTES).await;
            Ok(classify_response(status, body.as_deref()))
        }
    }

    fn validation_request(&self, token: &str) -> Option<ValidationRequest> {
        let mut url = reqwest::Url::parse(&self.endpoint).ok()?;
        let method = match self.method {
            ValidatorMethod::Post => "POST",
            ValidatorMethod::Get => "GET",
        };
        let mut headers = validation_default_header_pairs();
        let mut body = None;

        match (self.method, self.encoding) {
            (ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => {
                headers.push((
                    CONTENT_TYPE.as_str().to_string(),
                    "application/x-www-form-urlencoded".to_string(),
                ));
                body = Some(
                    url::form_urlencoded::Serializer::new(String::new())
                        .append_pair(&self.field, token)
                        .finish()
                        .into_bytes(),
                );
            }
            (ValidatorMethod::Post, TokenEncoding::Json) => {
                headers.push((
                    CONTENT_TYPE.as_str().to_string(),
                    "application/json".to_string(),
                ));
                body = Some(
                    serde_json::to_vec(&serde_json::json!({
                        self.field.as_str(): token
                    }))
                    .ok()?,
                );
            }
            (ValidatorMethod::Post, TokenEncoding::BearerHeader) => {
                headers.push(authorization_header_pair(token)?);
            }
            (ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => {
                url.query_pairs_mut().append_pair(&self.field, token);
            }
            (ValidatorMethod::Get, TokenEncoding::Json) => {
                headers.push(("X-Token".to_string(), header_value_string(token)?));
            }
            (ValidatorMethod::Get, TokenEncoding::BearerHeader) => {
                headers.push(authorization_header_pair(token)?);
            }
        }

        Some(ValidationRequest {
            method,
            url: url.to_string(),
            headers,
            body,
        })
    }
}

/// Maximum number of bytes to read from a verify-endpoint response
/// before truncating. Block-phrase detection only needs the first
/// few KiB; this cap is generous to leave room for verbose vendor
/// error JSON without ever allowing an unbounded read.
///
/// Consumed only by the reqwest path of [`TokenValidator::validate`]; the
/// `tls-impersonate` path caps inside `waf_gate::send_validation` via the same
/// [`crate::waf_gate::VALIDATION_BODY_CAP`], so this alias is gated to match its
/// sole consumer.
#[cfg(not(feature = "tls-impersonate"))]
const MAX_VALIDATION_BODY_BYTES: usize = crate::waf_gate::VALIDATION_BODY_CAP;

fn validation_default_header_pairs() -> Vec<(String, String)> {
    guise::http::default_browser_request_headers_without_compression(
        guise::http::BrowserRequestKind::SameOriginFetch,
    )
    .into_iter()
    .map(|header| (header.name.to_string(), header.value))
    .collect()
}

fn authorization_header_pair(token: &str) -> Option<(String, String)> {
    Some((
        AUTHORIZATION.as_str().to_string(),
        header_value_string(&format!("Bearer {token}"))?,
    ))
}

fn header_value_string(raw: &str) -> Option<String> {
    let value = HeaderValue::from_str(raw).ok()?;
    value.to_str().ok().map(str::to_string)
}

// Bounded body reader for the reqwest validation path only; the
// `tls-impersonate` path reads+caps inside `waf_gate::send_validation`.
#[cfg(not(feature = "tls-impersonate"))]
async fn read_capped_body(mut response: reqwest::Response, max: usize) -> Option<String> {
    // Use `Response::chunk()` rather than `bytes_stream()` so the
    // crate doesn't need the `stream` feature of reqwest enabled.
    let mut buf: Vec<u8> = Vec::new();
    loop {
        let chunk = response.chunk().await.ok().flatten();
        match chunk {
            Some(bytes) => {
                if buf.len() + bytes.len() > max {
                    let remaining = max.saturating_sub(buf.len());
                    buf.extend_from_slice(&bytes[..remaining]);
                    break;
                }
                buf.extend_from_slice(&bytes);
            }
            None => break,
        }
    }
    // Truncate on a UTF-8 boundary before lossy-decoding so a block
    // phrase straddling the cap doesn't get split mid-codepoint into
    // an unrecognisable replacement character. Walk back at most 3
    // bytes (that's the max width of a UTF-8 continuation tail).
    let mut end = buf.len();
    for _ in 0..3 {
        if std::str::from_utf8(&buf[..end]).is_ok() {
            break;
        }
        if end == 0 {
            break;
        }
        end -= 1;
    }
    Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}

/// Pure classifier, exposed so synthetic tests can exercise it
/// without spinning up an HTTP server.
pub fn classify_response(status: u16, body: Option<&str>) -> ValidationVerdict {
    if !(200..300).contains(&status) {
        return ValidationVerdict::Rejected;
    }
    if let Some(body) = body {
        let lower = body.to_lowercase();
        if BLOCK_PHRASES.iter().any(|p| lower.contains(p)) {
            return ValidationVerdict::Rejected;
        }
    }
    ValidationVerdict::Accepted
}

#[cfg(test)]
#[path = "token_oracle/tests.rs"]
mod tests;