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
//! WAF-gate preflight and token-verify HTTP via canonical substrate.
//!
//! - **TLS layer:** [`scanclient::StealthClient`] when the `tls-impersonate`
//!   feature is enabled (Phase 08).
//! - **Block disposition:** [`crate::detect::block_response`] + erroracle markers
//!   (Phase 12).
//!
//! Named WAF product ID stays in `wafrift-detect`; captcha *type* detection stays
//! in [`crate::detect`].

#[cfg(feature = "tls-impersonate")]
use std::time::Duration;

// Consumed solely by `classify_gate_response`, which is gated on the same cfg;
// without this the import dangles under a plain `cargo build --lib` (no
// `tls-impersonate`, no `test`). Gating it to the consumer's exact cfg keeps the
// two coherent (drop the consumer and the import warning returns).
#[cfg(any(feature = "tls-impersonate", test))]
use crate::detect::block_response;

/// Shared with [`crate::solver::token_oracle`] verify-endpoint reads.
pub(crate) const VALIDATION_BODY_CAP: usize = 64 * 1024;

/// Outcome of a GET preflight against a gated URL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateStatus {
    /// Response looks reachable (no block markers on a 2xx body).
    Clear,
    /// Block page or non-success status with block markers.
    Blocked {
        /// HTTP status from the upstream.
        status: u16,
    },
    /// Network/TLS failure or feature disabled (caller should not treat as clear).
    Inconclusive,
}

/// Default browser TLS profile for gate probes (Chrome 131 JA3).
#[cfg(feature = "tls-impersonate")]
#[must_use]
pub fn default_tls_profile() -> scanclient::tls_impersonate::ImpersonateProfile {
    scanclient::tls_impersonate::ImpersonateProfile::Chrome131
}

/// GET preflight with the default Chrome131 TLS profile.
pub async fn preflight_get(url: &str) -> GateStatus {
    #[cfg(feature = "tls-impersonate")]
    {
        return preflight_get_with_profile(url, default_tls_profile()).await;
    }
    #[cfg(not(feature = "tls-impersonate"))]
    {
        let _ = url;
        GateStatus::Inconclusive
    }
}

/// GET preflight wearing the given browser TLS profile.
#[cfg(feature = "tls-impersonate")]
pub async fn preflight_get_with_profile(
    url: &str,
    profile: scanclient::tls_impersonate::ImpersonateProfile,
) -> GateStatus {
    use scanclient::StealthClient;

    let client = match StealthClient::with_timeout(profile, Duration::from_secs(15)) {
        Ok(c) => c,
        Err(_) => return GateStatus::Inconclusive,
    };
    let resp = match client.send("GET", url, &[], None, 64 * 1024).await {
        Ok(r) => r,
        Err(_) => return GateStatus::Inconclusive,
    };
    classify_gate_response(resp.status, &resp.body)
}

/// Send a validation HTTP request (token oracle path) with browser TLS when enabled.
#[cfg(feature = "tls-impersonate")]
pub(crate) async fn send_validation(
    method: &str,
    url: &str,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    timeout: Duration,
) -> Result<(u16, Option<String>), ()> {
    #[cfg(feature = "tls-impersonate")]
    {
        return send_validation_stealth(method, url, headers, body, timeout).await;
    }
    #[cfg(not(feature = "tls-impersonate"))]
    {
        let _ = (method, url, headers, body, timeout);
        Err(())
    }
}

#[cfg(feature = "tls-impersonate")]
async fn send_validation_stealth(
    method: &str,
    url: &str,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    timeout: Duration,
) -> Result<(u16, Option<String>), ()> {
    use scanclient::StealthClient;

    let client = StealthClient::with_timeout(default_tls_profile(), timeout).map_err(|_| ())?;
    let resp = client
        .send(method, url, &headers, body.as_deref(), VALIDATION_BODY_CAP)
        .await
        .map_err(|_| ())?;
    let text = String::from_utf8_lossy(&resp.body).into_owned();
    Ok((resp.status, Some(text)))
}

#[cfg(any(feature = "tls-impersonate", test))]
fn classify_gate_response(status: u16, body: &[u8]) -> GateStatus {
    let text = String::from_utf8_lossy(body);
    if block_response::http_response_indicates_block(status, Some(text.as_ref())) {
        GateStatus::Blocked { status }
    } else if (200..300).contains(&status) {
        GateStatus::Clear
    } else {
        GateStatus::Blocked { status }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_clear_on_benign_200() {
        assert_eq!(
            classify_gate_response(200, b"<html>ok</html>"),
            GateStatus::Clear
        );
    }

    #[test]
    fn classify_blocked_on_erroracle_phrase() {
        assert!(matches!(
            classify_gate_response(200, b"Sorry, your request has been blocked"),
            GateStatus::Blocked { status: 200 }
        ));
    }

    #[test]
    fn classify_blocked_on_403() {
        assert!(matches!(
            classify_gate_response(403, b""),
            GateStatus::Blocked { status: 403 }
        ));
    }
}