captchaforge 0.2.40

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
//! Unit tests for [`super`], transcript cleaning, rate-limit phrase matching,
//! audio-URL validation, cookie-scoped replay, and the shared audio-subresource headers.

use super::*;
use crate::cookies::CapturedCookie;
use guise::fingerprint::default_profile_facts;

#[test]
fn clean_transcript_strips_punctuation_and_lowercases() {
    assert_eq!(clean_transcript("Five Seven, Nine!"), "five seven nine");
}

#[test]
fn clean_transcript_collapses_whitespace() {
    assert_eq!(clean_transcript("a   b\t\tc\nd"), "a b c d");
}

#[test]
fn clean_transcript_keeps_numbers() {
    assert_eq!(clean_transcript("5 7 9 2 6"), "5 7 9 2 6");
}

#[test]
fn clean_transcript_drops_unicode_punctuation() {
    // STT engines occasionally emit smart quotes; we're just
    // ignoring them rather than mapping them to an alphabet
    // character, alphanumeric() catches letters across scripts
    // and the rest is dropped.
    assert_eq!(clean_transcript("\u{201C}hello\u{201D}"), "hello");
}

#[test]
fn clean_transcript_empty_input_is_empty() {
    assert_eq!(clean_transcript(""), "");
    assert_eq!(clean_transcript("   "), "");
    assert_eq!(clean_transcript(",,,..."), "");
}

#[test]
fn rate_limit_matches_known_phrases() {
    for p in RATE_LIMIT_PHRASES {
        assert!(is_rate_limited(p), "should match: {p}");
        assert!(
            is_rate_limited(&p.to_uppercase()),
            "case-insensitive match should hold for: {p}",
        );
    }
}

#[test]
fn rate_limit_substring_match_works() {
    assert!(is_rate_limited(
        "Sorry (please try again later. We have detected unusual traffic from your network)."
    ));
}

#[test]
fn rate_limit_does_not_match_normal_prompts() {
    assert!(!is_rate_limited(
        "Press PLAY to listen and type what you hear"
    ));
    assert!(!is_rate_limited(""));
}

#[test]
fn audio_url_accepts_recaptcha_host() {
    assert!(looks_like_audio_url(
        "https://www.google.com/recaptcha/api2/payload?p=ASDF"
    ));
}

#[test]
fn audio_url_accepts_mp3_suffix_on_other_hosts() {
    assert!(looks_like_audio_url("https://cdn.example.com/clip.mp3"));
    assert!(looks_like_audio_url("http://x.test/y.mp3"));
}

#[test]
fn audio_url_rejects_data_blob_relative_empty() {
    assert!(!looks_like_audio_url(""));
    assert!(!looks_like_audio_url("data:audio/mpeg;base64,abc"));
    assert!(!looks_like_audio_url("blob:https://x.test/abc"));
    assert!(!looks_like_audio_url("//google.com/recaptcha/x.mp3"));
    assert!(!looks_like_audio_url("/relative/clip.mp3"));
}

#[test]
fn solver_constructs_with_default_endpoint() {
    let s = RecaptchaAudioSolver::new();
    assert_eq!(s.stt_endpoint, "http://localhost:9000/asr");
    assert_eq!(s.name(), "RecaptchaAudioSolver");
    assert_eq!(s.method(), SolveMethod::AudioBypass);
}

fn captured_header<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
    raw.lines().find_map(|line| {
        let (key, value) = line.split_once(':')?;
        key.eq_ignore_ascii_case(name).then(|| value.trim())
    })
}

#[tokio::test]
async fn recaptcha_audio_client_uses_shared_audio_subresource_headers() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://{}/clip.mp3", listener.local_addr().unwrap());
    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut request = Vec::new();
        let mut buf = [0_u8; 1024];
        loop {
            let n = socket.read(&mut buf).await.unwrap();
            if n == 0 {
                break;
            }
            request.extend_from_slice(&buf[..n]);
            if request.windows(4).any(|window| window == b"\r\n\r\n") {
                break;
            }
        }
        socket
            .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
            .await
            .unwrap();
        String::from_utf8(request).unwrap()
    });

    let response = RecaptchaAudioSolver::new()
        .client
        .get(&url)
        .send()
        .await
        .expect("reCAPTCHA audio request");
    assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);

    let raw_request = server.await.unwrap();
    let facts = default_profile_facts();
    assert_eq!(
        captured_header(&raw_request, "User-Agent"),
        Some(facts.user_agent)
    );
    assert_eq!(captured_header(&raw_request, "Accept"), Some("*/*"));
    assert_eq!(
        captured_header(&raw_request, "Accept-Language"),
        Some(facts.accept_language)
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Dest"),
        Some("audio")
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Mode"),
        Some("no-cors")
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Site"),
        Some("cross-site")
    );
    assert!(captured_header(&raw_request, "Accept-Encoding").is_none());
    assert!(captured_header(&raw_request, "Upgrade-Insecure-Requests").is_none());
    assert!(captured_header(&raw_request, "Sec-Fetch-User").is_none());
}

fn ck(name: &str, value: &str, domain: &str, path: &str, secure: bool) -> CapturedCookie {
    CapturedCookie {
        name: name.into(),
        value: value.into(),
        domain: domain.into(),
        path: path.into(),
        expires: None,
        secure,
        http_only: false,
        same_site: None,
    }
}

#[test]
fn recaptcha_audio_cookie_header_replays_only_url_scoped_safe_cookies() {
    let url = reqwest::Url::parse("https://www.google.com/recaptcha/api2/payload?p=clip").unwrap();
    let mut expired = ck("expired", "old", ".google.com", "/", false);
    expired.expires = Some(1);
    let cookies = vec![
        ck("NID", "abc", ".google.com", "/", false),
        ck(
            "__Secure-3PSID",
            "secure",
            ".google.com",
            "/recaptcha",
            true,
        ),
        ck(
            "host_only",
            "one",
            "www.google.com",
            "/recaptcha/api2",
            false,
        ),
        ck("wrong_host", "skip", ".example.com", "/", false),
        ck("wrong_path", "skip", ".google.com", "/accounts", false),
        ck("bad;name", "skip", ".google.com", "/", false),
        ck("bad_value", "skip;me", ".google.com", "/", false),
        expired,
    ];

    let header = recaptcha_audio_cookie_header(&cookies, &url)
        .expect("matching cookies should produce a header");

    assert_eq!(
        header.to_str().unwrap(),
        "NID=abc; __Secure-3PSID=secure; host_only=one"
    );
}

#[test]
fn solver_with_custom_endpoint() {
    let s = RecaptchaAudioSolver::new().with_stt_endpoint("http://stt.test:5000/v1");
    assert_eq!(s.stt_endpoint, "http://stt.test:5000/v1");
}

#[test]
fn solver_supports_recaptcha_v2_and_audio_kinds() {
    use crate::captcha_detect::DetectedCaptcha;
    let s = RecaptchaAudioSolver::new();
    assert!(s.supports(&DetectedCaptcha::RecaptchaV2));
    assert!(s.supports(&DetectedCaptcha::AudioCaptcha));
    assert!(!s.supports(&DetectedCaptcha::HCaptcha));
    assert!(!s.supports(&DetectedCaptcha::Turnstile));
    assert!(!s.supports(&DetectedCaptcha::None));
}

#[test]
fn selectors_are_stable_strings() {
    // These are the contract, if Google ever changes them, the
    // change shows up here as a diff and a regression fixture
    // gets added at the same time.
    assert_eq!(AUDIO_BUTTON_SELECTOR, "#recaptcha-audio-button");
    assert_eq!(RESPONSE_INPUT_SELECTOR, "#audio-response");
    assert_eq!(VERIFY_BUTTON_SELECTOR, "#recaptcha-verify-button");
    assert_eq!(TOKEN_INPUT_NAME, "g-recaptcha-response");
    assert!(RECAPTCHA_BFRAME_SELECTOR.contains("api2/bframe"));
}

#[test]
fn max_audio_retries_is_bounded_small() {
    // A high cap would burn IP reputation on a single solve; a
    // cap of 0 would defeat the multi-clip flow Google sometimes
    // serves. Two is the sweet spot, pin it so future edits
    // notice if someone bumps it.
    assert_eq!(MAX_AUDIO_RETRIES, 2);
}