captchaforge 0.2.33

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Cloudflare Turnstile interactive solver.
//!
//! Turnstile ships in three flavours:
//!
//! 1. **Invisible** — JS-only, no UI. Passive fingerprint scoring.
//!    Handled by [`super::super::WaitForTokenSolver`].
//! 2. **Non-interactive** — UI shows a spinner, no checkbox. Passes
//!    on a clean fingerprint, otherwise escalates to interactive.
//!    Also handled by `WaitForTokenSolver`.
//! 3. **Managed (interactive)** — UI shows a "Verify you are human"
//!    checkbox in an iframe. User must click. After the click,
//!    Cloudflare optionally escalates to a managed challenge
//!    (image grid, additional checkbox). This solver handles flavour 3.
//!
//! The generic [`super::super::BehavioralCaptchaSolver`]
//! already attempts a checkbox click for Turnstile, but it doesn't:
//!
//! - Locate the checkbox via iframe-geometry when `contentDocument`
//!   is opaque (the production case — Cloudflare's iframe is
//!   cross-origin, so `iframe.contentDocument` is null).
//! - Approach with multi-second warmup (Turnstile scores
//!   inactivity-then-click as bot-like).
//! - Time the press realistically (sub-50ms or super-300ms hold both
//!   scored as automation).
//! - Detect and dispatch the post-click managed challenge.
//!
//! This solver fixes all four.
//!
//! # Strategy
//!
//! 1. Wait for an iframe whose `src` starts with
//!    `https://challenges.cloudflare.com/cdn-cgi/challenge-platform/`.
//! 2. Read the iframe's bounding rect from the *parent* document
//!    (which we always have access to). The checkbox sits at a
//!    well-known offset inside the iframe: roughly (28, 28) from
//!    the iframe's top-left for the standard 300×65 widget.
//! 3. Pre-click warmup: 800–1500ms of bezier mouse meander over the
//!    page, no clicks. Turnstile's risk model penalises clicks that
//!    arrive within ~200ms of any mouse activity.
//! 4. Approach the checkbox from a randomised direction with one
//!    overshoot+correction. Hold the press for 70–110ms (the human
//!    median per W3C input timing studies).
//! 5. Poll for the token via the standard
//!    `cf-turnstile-response` field for up to 8 seconds. If a managed
//!    challenge appears (a second iframe whose URL contains
//!    `challenge-platform/scripts/...`), surface a screenshot for VLM
//!    fallback rather than spinning blindly.

use rand::{rngs::StdRng, Rng, SeedableRng};
use tracing::{debug, info};

use super::super::*;

/// Selector that matches the production Turnstile widget iframe.
///
/// Public for tests; the source URL pattern has been stable since
/// Turnstile GA in 2023. If Cloudflare ever changes it, fix the
/// selector AND add a regression fixture so we don't silently miss
/// the new shape.
pub const TURNSTILE_IFRAME_SELECTOR: &str =
    r#"iframe[src*="challenges.cloudflare.com/cdn-cgi/challenge-platform"]"#;

/// X offset of the checkbox centre from the iframe's top-left, in
/// CSS pixels. Measured against the standard 300×65 light/dark
/// theme; unchanged across the 2023–2026 widget revisions.
pub const CHECKBOX_OFFSET_X: f64 = 28.0;
/// Y offset of the checkbox centre.
pub const CHECKBOX_OFFSET_Y: f64 = 32.0;

/// Solver for the interactive Turnstile widget.
///
/// `#[derive(Default)]` — no per-instance state. Construct fresh
/// per chain or reuse; behaviour is identical.
#[derive(Default, Debug)]
pub struct TurnstileInteractiveSolver {
    config: SolveConfig,
}

impl TurnstileInteractiveSolver {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_config(mut self, config: SolveConfig) -> Self {
        self.config = config;
        self
    }

    /// Find the Turnstile iframe's bounding rect in the parent
    /// document. Returns `(left, top, width, height)` in CSS pixels.
    ///
    /// Falls back to `None` when the iframe isn't present yet —
    /// caller polls.
    async fn find_iframe_rect(page: &Page) -> Result<Option<(f64, f64, f64, f64)>> {
        let js = format!(
            r#"(() => {{
                const f = document.querySelector({sel});
                if (!f) return null;
                const r = f.getBoundingClientRect();
                if (r.width < 10 || r.height < 10) return null;
                return [r.left, r.top, r.width, r.height];
            }})()"#,
            sel = serde_json::to_string(TURNSTILE_IFRAME_SELECTOR).unwrap(),
        );
        match page.evaluate(js.as_str()).await {
            Ok(v) => Ok(v
                .into_value::<Option<(f64, f64, f64, f64)>>()
                .unwrap_or(None)),
            Err(_) => Ok(None),
        }
    }

    /// Detect whether Cloudflare escalated to a managed-challenge
    /// sub-iframe after the checkbox click. The managed challenge
    /// iframe's URL contains `challenge-platform/scripts/`.
    async fn managed_challenge_present(page: &Page) -> bool {
        let js = r#"(() => {
            const fs = document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]');
            for (const f of fs) {
                if (f.src.includes('challenge-platform/scripts/')) return true;
            }
            return false;
        })()"#;
        page.evaluate(js)
            .await
            .ok()
            .and_then(|v| v.into_value::<bool>().ok())
            .unwrap_or(false)
    }

    /// Pre-click mouse meander. Two human-trace replays with a brief
    /// pause — Turnstile's risk model wants to see SOME mouse activity
    /// that doesn't immediately resolve to the checkbox, and the
    /// trace-replay path produces an event distribution that doesn't
    /// match the parametric-bezier fingerprint.
    async fn warmup(page: &Page, rng: &mut StdRng) -> Result<()> {
        for _ in 0..2 {
            let x1 = rng.gen_range(50.0..900.0_f64);
            let y1 = rng.gen_range(50.0..600.0_f64);
            let x2 = rng.gen_range(50.0..900.0_f64);
            let y2 = rng.gen_range(50.0..600.0_f64);
            let _ = crate::behavior::mouse_move_human(page, x1, y1, x2, y2).await;
            tokio::time::sleep(Duration::from_millis(rng.gen_range(120..380))).await;
        }
        Ok(())
    }
}

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

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

    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
        // Tightly scoped — only Turnstile. The generic behavioral
        // solver still handles other vendors. Routing both into the
        // same chain is fine: this one runs first for Turnstile and
        // falls through on iframe-not-found, then BehavioralCaptchaSolver
        // gets a turn.
        matches!(kind, crate::captcha_detect::DetectedCaptcha::Turnstile)
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let mut rng = StdRng::from_entropy();

        // Step 0: token may already be there (passive Turnstile).
        if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
            .await
            .unwrap_or(false)
        {
            info!("Turnstile token already populated, no click needed");
            return Ok(CaptchaSolveResult {
                solution: "turnstile:passive".into(),
                confidence: 0.95,
                method: SolveMethod::AutoPass,
                time_ms: t0.elapsed().as_millis() as u64,
                success: true,
                screenshot: None,
                cookies: crate::cookies::capture_from_page(page)
                    .await
                    .unwrap_or_default(),
                verified_outcome: None,
            });
        }

        // Step 1: locate the iframe.
        let mut rect = None;
        for attempt in 0..self.config.checkbox_max_attempts {
            tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
            if let Some(r) = Self::find_iframe_rect(page).await? {
                rect = Some(r);
                debug!(attempt, ?r, "found Turnstile iframe");
                break;
            }
        }
        let (left, top, _w, _h) = rect.ok_or_else(|| {
            anyhow!(
                "Turnstile iframe not found after {} attempts",
                self.config.checkbox_max_attempts
            )
        })?;

        // Step 2: warmup.
        let _ = Self::warmup(page, &mut rng).await;

        // Step 3: approach the checkbox via a real-human trajectory
        // replay. The checkbox sits at a stable offset inside the
        // iframe (CHECKBOX_OFFSET_*); the trace library randomises
        // which curvature shape is used so back-to-back solves don't
        // produce identical pointer-event fingerprints.
        let target_x = left + CHECKBOX_OFFSET_X + rng.gen_range(-3.0..3.0);
        let target_y = top + CHECKBOX_OFFSET_Y + rng.gen_range(-3.0..3.0);
        let approach_x = target_x + rng.gen_range(-180.0..180.0);
        let approach_y = target_y + rng.gen_range(-90.0..90.0);
        crate::behavior::mouse_move_human(page, approach_x, approach_y, target_x, target_y).await?;

        // Step 4: realistic-timing click. The behavior helper does
        // press+release with sub-frame timing; that's still better
        // than CDP's instant click but Turnstile is sensitive enough
        // that we add the tiny jitter outside the helper too.
        tokio::time::sleep(Duration::from_millis(rng.gen_range(40..90))).await;
        crate::behavior::click_realistic(page, target_x, target_y).await?;

        // Step 5: poll for the token, but bail to VLM if the managed
        // challenge subdiv appears (we shouldn't keep waiting on the
        // same widget after Cloudflare has decided to escalate).
        for attempt in 0..self.config.token_max_attempts {
            tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms)).await;
            if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
                .await
                .unwrap_or(false)
            {
                info!(attempt, "Turnstile token populated after click");
                return Ok(CaptchaSolveResult {
                    solution: "turnstile:interactive".into(),
                    confidence: 0.9,
                    method: SolveMethod::BehavioralBypass,
                    time_ms: t0.elapsed().as_millis() as u64,
                    success: true,
                    screenshot: None,
                    cookies: crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default(),
                    verified_outcome: None,
                });
            }
            if Self::managed_challenge_present(page).await {
                debug!("Cloudflare escalated to managed challenge — yielding to VLM");
                let shot = super::super::screenshot_b64(page).await.ok();
                return Ok(CaptchaSolveResult {
                    solution: String::new(),
                    confidence: 0.0,
                    method: SolveMethod::BehavioralBypass,
                    time_ms: t0.elapsed().as_millis() as u64,
                    success: false,
                    screenshot: shot,
                    cookies: Vec::new(),
                    verified_outcome: None,
                });
            }
        }

        // Token never showed and no managed challenge either — likely
        // a fingerprint reject. Failure result; chain may try VLM /
        // ThirdParty next.
        Ok(CaptchaSolveResult::failure(
            SolveMethod::BehavioralBypass,
            t0.elapsed().as_millis() as u64,
        ))
    }
}

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

    #[test]
    fn checkbox_offsets_match_standard_widget() {
        // The 300x65 standard widget puts the checkbox at roughly
        // (28, 32) from the iframe origin. If Cloudflare ever ships
        // a redesign that moves it, we'll see this constant change
        // — keeping it here as a single source of truth means the
        // patch is two lines plus a regression fixture, not a
        // multi-file hunt.
        let x = CHECKBOX_OFFSET_X;
        let y = CHECKBOX_OFFSET_Y;
        assert!(x > 0.0 && x < 60.0, "X offset out of widget bounds");
        assert!(y > 0.0 && y < 60.0, "Y offset out of widget bounds");
    }

    #[test]
    fn iframe_selector_matches_production_pattern() {
        // The production Turnstile iframe URL pattern, sampled
        // 2026-05-11 against challenges.cloudflare.com:
        //   https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/if/...
        // Our selector must match this. If it doesn't, the solver
        // never finds the widget.
        let live_url =
            "https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/if/abcd";
        // Substring check approximates `iframe[src*=...]` semantics
        // — same fragment the selector greps for.
        assert!(live_url.contains("challenges.cloudflare.com/cdn-cgi/challenge-platform"));
    }

    #[test]
    fn supports_only_turnstile() {
        use crate::captcha_detect::DetectedCaptcha;
        let s = TurnstileInteractiveSolver::new();
        assert!(s.supports(&DetectedCaptcha::Turnstile));
        for other in [
            DetectedCaptcha::RecaptchaV2,
            DetectedCaptcha::HCaptcha,
            DetectedCaptcha::PowCaptcha,
            DetectedCaptcha::SliderCaptcha,
            DetectedCaptcha::ImageCaptcha,
            DetectedCaptcha::AudioCaptcha,
            DetectedCaptcha::CanvasCaptcha,
            DetectedCaptcha::ShadowDomCaptcha,
            DetectedCaptcha::MultiStepCaptcha,
            DetectedCaptcha::Custom("datadome".into()),
        ] {
            assert!(
                !s.supports(&other),
                "TurnstileInteractive must not claim {other:?}"
            );
        }
    }

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

    #[test]
    fn default_constructor_uses_default_solveconfig() {
        let s = TurnstileInteractiveSolver::default();
        let dflt = SolveConfig::default();
        assert_eq!(
            s.config.checkbox_poll_interval_ms,
            dflt.checkbox_poll_interval_ms
        );
        assert_eq!(s.config.token_max_attempts, dflt.token_max_attempts);
    }

    #[test]
    fn with_config_overrides_solveconfig() {
        let custom = SolveConfig {
            checkbox_poll_interval_ms: 250,
            token_max_attempts: 32,
            ..SolveConfig::default()
        };
        let s = TurnstileInteractiveSolver::new().with_config(custom);
        assert_eq!(s.config.checkbox_poll_interval_ms, 250);
        assert_eq!(s.config.token_max_attempts, 32);
    }
}