captchaforge 0.2.13

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
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 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,
}

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 client = reqwest::Client::builder()
            .timeout(self.timeout)
            .build()?;

        let req = match (self.method, self.encoding) {
            (ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => client
                .post(&self.endpoint)
                .form(&[(self.field.as_str(), token)]),
            (ValidatorMethod::Post, TokenEncoding::Json) => client
                .post(&self.endpoint)
                .json(&serde_json::json!({ self.field.as_str(): token })),
            (ValidatorMethod::Post, TokenEncoding::BearerHeader) => client
                .post(&self.endpoint)
                .bearer_auth(token),
            (ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => client
                .get(&self.endpoint)
                .query(&[(self.field.as_str(), token)]),
            (ValidatorMethod::Get, TokenEncoding::Json) => client
                .get(&self.endpoint)
                .header("X-Token", token),
            (ValidatorMethod::Get, TokenEncoding::BearerHeader) => client
                .get(&self.endpoint)
                .bearer_auth(token),
        };

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

        Ok(classify_response(response.status().as_u16(), response.text().await.ok().as_deref()))
    }
}

/// 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)]
mod tests {
    use super::*;

    #[test]
    fn classify_4xx_rejected() {
        assert_eq!(classify_response(403, None), ValidationVerdict::Rejected);
        assert_eq!(classify_response(429, None), ValidationVerdict::Rejected);
    }

    #[test]
    fn classify_5xx_rejected() {
        assert_eq!(classify_response(500, None), ValidationVerdict::Rejected);
        assert_eq!(classify_response(503, None), ValidationVerdict::Rejected);
    }

    #[test]
    fn classify_2xx_clean_body_accepted() {
        assert_eq!(
            classify_response(200, Some("{\"ok\": true}")),
            ValidationVerdict::Accepted,
        );
        assert_eq!(
            classify_response(204, None),
            ValidationVerdict::Accepted,
        );
    }

    #[test]
    fn classify_2xx_with_block_phrase_rejected() {
        // A vendor that returns 200 with a "Sorry, you have been
        // blocked" body is still a rejection — the token wasn't
        // accepted, the page just didn't bother with a 4xx.
        assert_eq!(
            classify_response(200, Some("Sorry, you have been blocked.")),
            ValidationVerdict::Rejected,
        );
        assert_eq!(
            classify_response(200, Some("Access Denied")),
            ValidationVerdict::Rejected,
        );
        assert_eq!(
            classify_response(200, Some("Pardon Our Interruption")),
            ValidationVerdict::Rejected,
        );
    }

    #[test]
    fn classify_2xx_block_phrase_case_insensitive() {
        assert_eq!(
            classify_response(200, Some("REQUEST BLOCKED")),
            ValidationVerdict::Rejected,
        );
    }

    #[test]
    fn validator_builder_chains() {
        let v = TokenValidator::new("https://x.test/v")
            .with_field("g-recaptcha-response")
            .with_encoding(TokenEncoding::Json)
            .with_method(ValidatorMethod::Get)
            .with_timeout(Duration::from_secs(2));
        assert_eq!(v.endpoint, "https://x.test/v");
        assert_eq!(v.field, "g-recaptcha-response");
        assert_eq!(v.encoding, TokenEncoding::Json);
        assert_eq!(v.method, ValidatorMethod::Get);
        assert_eq!(v.timeout, Duration::from_secs(2));
    }

    #[test]
    fn default_field_is_turnstile() {
        let v = TokenValidator::new("https://x.test/v");
        assert_eq!(v.field, "cf-turnstile-response");
    }
}