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
//! The reCAPTCHA audio subresource HTTP client + cookie-scoped replay. Fetching
//! the audio clip must look like the BROWSER fetching it: the canonical
//! audio-subresource header set (no compression rewrite) and only the cookies a
//! real browser would attach for that exact URL (domain/path/secure/expiry
//! scoped, header-injection-safe). Kept out of the solver flow so the scoping
//! predicates are independently testable.

use std::time::Duration;

/// Build the reqwest client used to fetch the audio clip, with the canonical
/// stealth audio-subresource headers baked in as defaults.
pub(crate) fn recaptcha_audio_client(timeout: Duration) -> reqwest::Client {
    reqwest::Client::builder()
        .timeout(timeout)
        .no_gzip()
        .no_brotli()
        .default_headers(recaptcha_audio_headers())
        .build()
        .expect("reCAPTCHA audio reqwest client must build with rustls-tls enabled")
}

/// The canonical browser audio-subresource request headers (shared with guise so
/// the order/casing matches a real `<audio>` fetch).
pub(crate) fn recaptcha_audio_headers() -> reqwest::header::HeaderMap {
    guise::http::default_browser_request_header_map_without_compression(
        guise::http::BrowserRequestKind::AudioSubresource,
    )
    .expect("canonical stealth audio-subresource headers must be valid")
}

/// Build the `Cookie` header for the audio fetch, replaying ONLY the captured
/// cookies that a browser would actually send to `audio_url` (URL-scoped,
/// unexpired, header-safe). Returns `None` when nothing is in scope.
pub(crate) fn recaptcha_audio_cookie_header(
    cookies: &[crate::cookies::CapturedCookie],
    audio_url: &reqwest::Url,
) -> Option<reqwest::header::HeaderValue> {
    let pairs = cookies
        .iter()
        .filter(|cookie| cookie_matches_url(cookie, audio_url))
        .filter(|cookie| cookie_pair_is_header_safe(&cookie.name, &cookie.value))
        .map(|cookie| format!("{}={}", cookie.name, cookie.value))
        .collect::<Vec<_>>();

    if pairs.is_empty() {
        return None;
    }

    reqwest::header::HeaderValue::from_str(&pairs.join("; ")).ok()
}

/// Whether `cookie` is in scope for `url`: unexpired, secure-scheme-matched, and
/// domain + path matching.
pub(crate) fn cookie_matches_url(
    cookie: &crate::cookies::CapturedCookie,
    url: &reqwest::Url,
) -> bool {
    if cookie.is_expired_now() {
        return false;
    }
    if cookie.secure && url.scheme() != "https" {
        return false;
    }
    let Some(host) = url.host_str() else {
        return false;
    };
    domain_matches(&cookie.domain, host) && path_matches(&cookie.path, url.path())
}

/// RFC 6265 domain-match: exact host, or a leading-dot cookie domain that the
/// host equals or is a subdomain of.
pub(crate) fn domain_matches(cookie_domain: &str, host: &str) -> bool {
    let cookie_domain = cookie_domain.trim().to_ascii_lowercase();
    if cookie_domain.is_empty() {
        return false;
    }
    let host = host.to_ascii_lowercase();
    let Some(domain) = cookie_domain.strip_prefix('.') else {
        return host == cookie_domain;
    };

    host == domain || host.ends_with(&format!(".{domain}"))
}

/// RFC 6265 path-match: cookie path `/`, exact match, or a prefix ending on a
/// path boundary.
pub(crate) fn path_matches(cookie_path: &str, request_path: &str) -> bool {
    let cookie_path = if cookie_path.is_empty() {
        "/"
    } else {
        cookie_path
    };
    if cookie_path == "/" || request_path == cookie_path {
        return true;
    }
    request_path.starts_with(cookie_path)
        && (cookie_path.ends_with('/')
            || request_path
                .as_bytes()
                .get(cookie_path.len())
                .is_some_and(|byte| *byte == b'/'))
}

/// Reject a cookie name/value that would break the `Cookie` header syntax (or
/// smuggle a second header), empty name, non-graphic bytes, or the `= ; , "`
/// delimiters.
pub(crate) fn cookie_pair_is_header_safe(name: &str, value: &str) -> bool {
    !name.is_empty()
        && name
            .bytes()
            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'=' | b';' | b',' | b'"'))
        && value
            .bytes()
            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b';'))
}