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
//! Live end-to-end: reynard engine + `TurnstileInteractiveSolver` vs a real,
//! FORCE-INTERACTIVE Cloudflare Turnstile (CF test sitekey `3x…FF`) on a local
//! page. The widget renders its checkbox in a real cross-origin OOPIF
//! (`challenges.cloudflare.com`), so this exercises the exact production case the
//! solver exists for, and the cross-origin checkbox click that foxdriver warns
//! a top-context click cannot reliably deliver.
//!
//! No outward-facing auth: CF's test sitekeys work on localhost and issue only a
//! dummy token, and only after a real interaction. Opt-in:
//! ```text
//! REYNARD_BIN=/…/dist/bin/camoufox DISPLAY=:1 MOZ_DISABLE_CONTENT_SANDBOX=1 \
//!   cargo test -p captchaforge --test turnstile_interactive_live -- --nocapture
//! ```

use std::time::Duration;

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolver, SolveConfig, TurnstileInteractiveSolver};
use guise::browser::launch_reynard;
use guise::fingerprint::StealthProfile;

mod common;

// `3x00000000000000000000FF` = CF's "force an interactive challenge" test
// sitekey: always renders the checkbox and requires a real interaction.
const PAGE: &str = r#"<!doctype html><html><head><meta charset="utf-8"><title>ts-interactive</title>
<script>
  window.onloadTurnstileCallback = function () {
    try {
      window.turnstile.render('#ts', { sitekey: '3x00000000000000000000FF' });
    } catch (e) { var d=document.getElementById('err'); if(d) d.textContent='render-err:'+e; }
  };
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" async defer></script>
</head><body>
<h1>Turnstile interactive test</h1>
<form id="f"><div id="ts" class="cf-turnstile"></div></form>
<div id="err"></div>
</body></html>"#;

/// Length of the `cf-turnstile-response` token the widget injects on success
/// (unquoted attribute selector avoids nested-quote escaping).
const TOKEN_LEN_JS: &str = r#"(function(){var e=document.querySelector('input[name=cf-turnstile-response]');return e?(e.value||'').length:0;})()"#;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reynard_solves_force_interactive_turnstile() {
    let Ok(reynard_bin) = std::env::var("REYNARD_BIN") else {
        eprintln!("SKIP turnstile_interactive_live: set REYNARD_BIN to run");
        return;
    };
    if std::env::var("DISPLAY").is_err() {
        eprintln!("SKIP turnstile_interactive_live: no DISPLAY");
        return;
    }

    let addr = common::serve_once(PAGE.to_string()).await;
    let url = format!("http://{addr}/");
    let page = launch_reynard(&reynard_bin, &StealthProfile::FirefoxLinux, false)
        .await
        .expect("launch reynard");
    page.goto(&url)
        .await
        .expect("navigate to local turnstile page");
    // Let the Turnstile widget render its cross-origin iframe.
    tokio::time::sleep(Duration::from_millis(3500)).await;

    // Diagnostic: did the widget render at all? (Turnstile may refuse a
    // non-secure context / unknown domain, distinguishes "didn't render" from
    // "rendered but click missed".)
    let dom = page
        .evaluate(
            r#"(function(){
                var ifr=document.querySelectorAll('iframe');
                var srcs=[];for(var i=0;i<ifr.length;i++){srcs.push((ifr[i].src||'').slice(0,60));}
                return JSON.stringify({
                    turnstileGlobal: typeof window.turnstile,
                    iframeCount: ifr.length,
                    iframeSrcs: srcs,
                    cfDiv: !!document.querySelector('.cf-turnstile'),
                    isSecure: window.isSecureContext,
                    bodyLen: document.body?document.body.innerText.length:0
                });
            })()"#,
        )
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    eprintln!("[ts-interactive] DOM after load: {dom}");

    // Expected skip: the `3x…FF` force-interactive TEST sitekey does NOT present an
    // interactive challenge iframe on a self-hosted page, in ANY Firefox. This is
    // a Cloudflare test-key/challenge-platform property, NOT a harness limitation or
    // a reynard tell: the bench `turnstile_interactive_reynard` differential proves
    // stock Firefox and reynard behave byte-identically here (both register the
    // widget, neither builds an iframe), over HTTP and HTTPS, on localhost / IP / a
    // real hostname. So there is simply no checkbox to click on this surface. The
    // cross-origin trusted-click mechanical path is instead proven deterministically
    // by foxdriver's `cross_origin_click` (4 positives + the isTrusted=false negative,
    // single + doubly-nested), and the COMMON managed-Turnstile case auto-passes with
    // reynard (`guise::reynard_managed_turnstile_demo`). Skip rather than false-fail.
    let iframe_count = page
        .evaluate("document.querySelectorAll('iframe').length")
        .await
        .ok()
        .and_then(|e| e.into_value::<u64>().ok())
        .unwrap_or(0);
    if iframe_count == 0 {
        eprintln!(
            "SKIP turnstile_interactive_live: the 3x…FF force-interactive TEST key presents \
             no challenge iframe on a self-hosted origin in any Firefox (stock == reynard, \
             proven by bench turnstile_interactive_reynard), no checkbox exists to click. \
             Mechanical click path is proven by foxdriver cross_origin_click. DOM: {dom}"
        );
        let _ = page.close().await;
        return;
    }

    let info = CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("3x00000000000000000000FF".into()),
        page_url: url.clone(),
        container_selector: Some(".cf-turnstile".into()),
    };
    let solver = TurnstileInteractiveSolver::new().with_config(SolveConfig {
        checkbox_max_attempts: 12,
        token_max_attempts: 20,
        ..SolveConfig::default()
    });
    let res = solver.solve(&page, &info).await;

    let token_len = page
        .evaluate(TOKEN_LEN_JS)
        .await
        .ok()
        .and_then(|e| e.into_value::<u64>().ok())
        .unwrap_or(0);
    let _ = page.close().await;

    let res = res.expect("solver returned an error");
    eprintln!(
        "[ts-interactive] success={} solution={:?} token_len={}",
        res.success, res.solution, token_len
    );
    assert!(
        res.success || token_len > 0,
        "TurnstileInteractiveSolver did NOT solve the force-interactive widget \
         (success={}, token_len={}), the cross-origin checkbox click likely did \
         not reach the OOPIF",
        res.success,
        token_len
    );
}