captchaforge 0.2.35

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Wait for the captcha widget to populate its response field
//! without explicit interaction.
//!
//! Most production Cloudflare Turnstile / hCaptcha invisible /
//! reCAPTCHA v3 deployments are *passive*: the widget loads, runs
//! the vendor's fingerprinting (browser features, behaviour,
//! IP reputation), and IF the visitor passes, fires the success
//! callback on its own — populating the relevant response form
//! field with a real token. No checkbox to click, no challenge to
//! solve. The chain just needs to wait, then read the token.
//!
//! This is the cheapest possible "solve" — no mouse simulation,
//! no VLM call, no third-party API charge. Putting it FIRST in the
//! chain means the common-case passive pass short-circuits the
//! expensive solvers entirely.
//!
//! What this solver is NOT:
//!
//! - It does not click anything. If the widget needs interaction
//!   (full Turnstile interactive challenge, reCAPTCHA v2 image
//!   grid, hCaptcha challenge tile), it will time out and return
//!   `success: false` — the chain then falls through to the
//!   behavioural / VLM / third-party layers.
//! - It does not fabricate or mock tokens. If the response field
//!   stays empty, the result is honest failure. The bench's
//!   real-WAF suite has block/interactive fixtures specifically
//!   to prove this — see `tests/real_waf.rs`.
//! - It does not validate the token server-side. If the vendor
//!   issues a real-shaped token that wouldn't pass siteverify
//!   (e.g. test-sitekey tokens), this solver still reports success
//!   because the page-side contract is satisfied. Server-side
//!   validation belongs in the consuming application, not in the
//!   captcha solver.

use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Duration;

/// Default poll interval between response-field checks.
const DEFAULT_POLL_INTERVAL_MS: u64 = 200;
/// Default total wait budget. 15s is generous: real production
/// Turnstile passive challenges complete in well under 5s; longer
/// waits usually mean the widget is stuck and falling through to
/// behavioural simulation is the right next step.
const DEFAULT_MAX_WAIT_MS: u64 = 15_000;

/// JS expression that, when evaluated, returns the first non-empty
/// response token from any of the well-known vendor response fields,
/// or `null` if none has been populated. Single round-trip per poll.
const TOKEN_PROBE_JS: &str = r#"
(() => {
    const probes = [
        'input[name="cf-turnstile-response"]',
        'textarea[name="cf-turnstile-response"]',
        '#cf-chl-widget-' /* Cloudflare interstitial */,
        'textarea[name="g-recaptcha-response"]',
        '#g-recaptcha-response',
        'textarea[name="h-captcha-response"]',
        'input[name="h-captcha-response"]',
        '[name="captchaToken"]',
        '[name="captcha_token"]',
        '[name="frc-captcha-solution"]' /* Friendly Captcha */,
        '[name="mtcaptcha-verifiedtoken"]' /* MTCaptcha */,
        '[name="altcha"]' /* ALTCHA */,
        '[name="mcaptcha__token"]' /* mCaptcha */,
        '[name="cap_token"]' /* Cap.dev */,
        '[name="aws-waf-token"]' /* AWS WAF */,
    ];
    /* Recursively walk light + shadow + same-origin iframe DOM so a
       captcha rendered inside a closed-from-light <captcha-widget>
       element with attachShadow() OR a same-origin nested iframe still
       surfaces its response field. Iterative BFS to avoid blowing the
       JS stack on deeply-nested trees. Cross-origin iframes throw on
       contentDocument access — we swallow and skip them, the same
       boundary a non-instrumented browser would hit. */
    function* walkAllRoots(root) {
        const queue = [root];
        const seen = new WeakSet();
        while (queue.length) {
            const r = queue.shift();
            if (seen.has(r)) continue;
            seen.add(r);
            yield r;
            const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
            for (const el of subtree) {
                if (el.shadowRoot) queue.push(el.shadowRoot);
                if (el.tagName === 'IFRAME') {
                    let inner = null;
                    try { inner = el.contentDocument; } catch (_) {}
                    if (inner) queue.push(inner);
                }
            }
        }
    }
    for (const root of walkAllRoots(document)) {
        for (const sel of probes) {
            let el;
            try { el = root.querySelector(sel); } catch (_) { continue; }
            if (!el) continue;
            const v = (el.value || el.textContent || el.innerHTML || '').trim();
            if (v && v.length > 0) return v;
        }
    }
    /* grecaptcha.getResponse() handles invisible reCAPTCHA where
       no DOM element holds the token directly. */
    if (typeof grecaptcha !== 'undefined') {
        try {
            const r = grecaptcha.getResponse();
            if (r && r.length > 0) return r;
        } catch (_) {}
    }
    return null;
})()
"#;

/// Solver that polls the page for any vendor's response token.
pub struct WaitForTokenSolver {
    poll_interval_ms: u64,
    max_wait_ms: u64,
}

impl Default for WaitForTokenSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl WaitForTokenSolver {
    pub fn new() -> Self {
        Self {
            poll_interval_ms: DEFAULT_POLL_INTERVAL_MS,
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    /// Override the poll interval (default 200ms).
    pub fn with_poll_interval_ms(mut self, ms: u64) -> Self {
        self.poll_interval_ms = ms;
        self
    }

    /// Override the total wait budget (default 15_000ms). Set to a
    /// short value (1-3s) when running first in a chain that has
    /// other solvers behind it — the passive-pass case populates
    /// the field within a few seconds; anything longer is a sign
    /// the widget needs interaction.
    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

#[async_trait]
impl CaptchaSolver for WaitForTokenSolver {
    fn name(&self) -> &'static str {
        "WaitForTokenSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::AutoPass
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Anything that can plausibly auto-populate a response field.
        // Slider / canvas / multi-step still get a chance — they
        // sometimes use the same form fields when configured to
        // auto-pass behaviourally.
        matches!(
            kind,
            DetectedCaptcha::Turnstile
                | DetectedCaptcha::RecaptchaV2
                | DetectedCaptcha::RecaptchaV3
                | DetectedCaptcha::HCaptcha
                | DetectedCaptcha::ImageCaptcha
                | DetectedCaptcha::PowCaptcha
                | DetectedCaptcha::SliderCaptcha
                | DetectedCaptcha::CanvasCaptcha
                | DetectedCaptcha::MultiStepCaptcha
                | DetectedCaptcha::ShadowDomCaptcha
                | DetectedCaptcha::Custom(_)
        )
    }

    async fn solve(
        &self,
        page: &Page,
        _info: &crate::captcha_detect::CaptchaInfo,
    ) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let deadline = t0 + Duration::from_millis(self.max_wait_ms);

        loop {
            let raw = page
                .evaluate(TOKEN_PROBE_JS)
                .await
                .map_err(|e| anyhow!("wait_for_token probe failed: {e}"))?;
            let token = raw.into_value::<Option<String>>().ok().flatten();
            if let Some(token) = token {
                if !token.is_empty() {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: token,
                        confidence: 1.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                        verified_outcome: None,
                    });
                }
            }
            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(self.poll_interval_ms)).await;
        }
    }
}

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

    #[test]
    fn defaults_are_sane() {
        let s = WaitForTokenSolver::new();
        assert_eq!(s.poll_interval_ms, 200);
        assert_eq!(s.max_wait_ms, 15_000);
    }

    #[test]
    fn builders_override_each_field() {
        let s = WaitForTokenSolver::new()
            .with_poll_interval_ms(50)
            .with_max_wait_ms(3_000);
        assert_eq!(s.poll_interval_ms, 50);
        assert_eq!(s.max_wait_ms, 3_000);
    }

    #[test]
    fn supports_covers_all_passive_pass_kinds() {
        let s = WaitForTokenSolver::new();
        assert!(s.supports(&DetectedCaptcha::Turnstile));
        assert!(s.supports(&DetectedCaptcha::RecaptchaV3));
        assert!(s.supports(&DetectedCaptcha::HCaptcha));
        assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
    }

    #[test]
    fn supports_returns_false_for_no_captcha() {
        assert!(!WaitForTokenSolver::new().supports(&DetectedCaptcha::None));
    }

    #[test]
    fn name_and_method_are_stable() {
        let s = WaitForTokenSolver::new();
        assert_eq!(s.name(), "WaitForTokenSolver");
        assert_eq!(s.method(), SolveMethod::AutoPass);
    }

    #[test]
    fn token_probe_js_walks_shadow_roots() {
        // Pin the shadow-DOM piercing so a refactor that drops it gets
        // caught — captchas inside `<captcha-widget>` shadow roots are
        // a known evasion pattern, see SHADOW_DOM_CAPTCHA fixture.
        assert!(TOKEN_PROBE_JS.contains("shadowRoot"));
        assert!(TOKEN_PROBE_JS.contains("walkAllRoots"));
    }

    #[test]
    fn token_probe_js_walks_same_origin_iframes() {
        // NESTED_IFRAMES fixture puts the response field 3 iframes
        // deep; the probe MUST walk contentDocument or that fixture
        // can never honestly resolve.
        assert!(TOKEN_PROBE_JS.contains("contentDocument"));
        assert!(TOKEN_PROBE_JS.contains("IFRAME"));
    }

    #[test]
    fn token_probe_js_covers_documented_vendor_fields() {
        // Pin the field set so adding/removing a vendor surface is a
        // visible change to this test, not a silent regression.
        let must_contain = [
            "cf-turnstile-response",
            "g-recaptcha-response",
            "h-captcha-response",
            "frc-captcha-solution",
            "mtcaptcha-verifiedtoken",
            "grecaptcha.getResponse",
        ];
        for needle in must_contain {
            assert!(
                TOKEN_PROBE_JS.contains(needle),
                "TOKEN_PROBE_JS must cover: {needle}"
            );
        }
    }
}