captchaforge 0.2.28

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Cloudflare Bot Manager interstitial ("Just a moment...") solver.
//!
//! Distinct from the Turnstile widget: this is the JavaScript-only
//! 5-second challenge page Cloudflare puts in front of protected
//! sites BEFORE the actual application loads. Common shape:
//!
//! 1. Page loads with title "Just a moment..." or "Attention Required!"
//! 2. CF runs an obfuscated JS challenge measuring browser features
//!    (canvas, WebGL, audio, performance timing, etc.) for ~5s
//! 3. On pass, CF sets the `cf_clearance` cookie and navigates to
//!    the protected URL (or to `/cdn-cgi/challenge-platform/h/g/orchestrate`
//!    which then redirects)
//! 4. The site is reachable for the cookie's lifetime (typically
//!    30 minutes)
//!
//! `CloudflareInterstitialSolver` doesn't reverse-engineer the
//! challenge — that's a moving target Cloudflare actively defends
//! against. Instead, it leans on the fact that with proper stealth
//! (which captchaforge applies via `crate::stealth`), the challenge
//! actually PASSES from a real-looking chromium session. We just
//! need to wait for it to complete and harvest the cookie.
//!
//! Without stealth this solver returns failure within the timeout
//! and the chain falls through. With stealth applied (and a real
//! Chrome UA) the success rate against test sites is very high.

use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};

/// Default total wait budget. Cloudflare's challenge typically
/// completes within 5-7s; 20s is a comfortable upper bound that
/// still falls through cleanly when the challenge is actually
/// blocking us.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// Solver for the Cloudflare interstitial challenge.
pub struct CloudflareInterstitialSolver {
    max_wait_ms: u64,
}

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

impl CloudflareInterstitialSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

/// JS that probes the page for the interstitial signals AND for
/// the cf_clearance cookie. Returns one of:
///   - `{ phase: "challenge" }` — still on the challenge page
///   - `{ phase: "passed", cookieSet: true|false }` — page navigated past it
///   - `{ phase: "blocked" }` — explicit block (CF rejected the visitor)
const PHASE_PROBE_JS: &str = r#"
(() => {
    const title = (document.title || '').toLowerCase();
    const body = document.body ? document.body.innerHTML : '';
    if (title.includes('blocked') || title.includes('access denied')) {
        return { phase: 'blocked' };
    }
    if (
        title.includes('just a moment') ||
        title.includes('attention required') ||
        document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification, #cf-challenge-running')
    ) {
        return { phase: 'challenge' };
    }
    /* Page is no longer on the interstitial — check cookie. */
    const hasClearance = document.cookie.split(';').some(c => c.trim().startsWith('cf_clearance='));
    return { phase: 'passed', cookieSet: hasClearance };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    #[serde(rename = "cookieSet")]
    cookie_set: bool,
}

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // ChallengePageDetector maps the interstitial to Turnstile.
        // We claim that kind specifically — narrow enough not to
        // collide with TurnstileDetector's widget case (which is
        // routed by the page's own widget DOM, distinguished at the
        // detector layer not the solver).
        matches!(
            kind,
            DetectedCaptcha::Turnstile | DetectedCaptcha::Custom(_)
        )
    }

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

        loop {
            let raw = page
                .evaluate(PHASE_PROBE_JS)
                .await
                .map_err(|e| anyhow!("CF interstitial probe failed: {e}"))?;
            let probe: PhaseProbe = match raw.into_value() {
                Ok(p) => p,
                Err(_) => PhaseProbe {
                    phase: "challenge".to_string(),
                    cookie_set: false,
                },
            };

            match probe.phase.as_str() {
                "passed" if probe.cookie_set => {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: "cf_clearance".to_string(),
                        confidence: 1.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                        verified_outcome: None,
                    });
                }
                "passed" => {
                    // Page passed the interstitial but no cookie yet
                    // — give it a moment for the cookie to land.
                }
                "blocked" => {
                    // Cloudflare explicitly rejected the visitor.
                    // No amount of waiting will help.
                    return Ok(CaptchaSolveResult::failure(
                        SolveMethod::AutoPass,
                        t0.elapsed().as_millis() as u64,
                    ));
                }
                _ => {} // still on challenge — keep waiting
            }

            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }
}

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

    #[test]
    fn defaults_are_sane() {
        let s = CloudflareInterstitialSolver::new();
        assert_eq!(s.max_wait_ms, 20_000);
    }

    #[test]
    fn builders_override() {
        let s = CloudflareInterstitialSolver::new().with_max_wait_ms(8_000);
        assert_eq!(s.max_wait_ms, 8_000);
    }

    #[test]
    fn supports_turnstile_kind() {
        assert!(CloudflareInterstitialSolver::new().supports(&DetectedCaptcha::Turnstile));
    }

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

    #[test]
    fn phase_probe_js_covers_documented_signals() {
        for needle in [
            "just a moment",
            "attention required",
            "#challenge-running",
            "#challenge-form",
            "cf_clearance",
            "blocked",
        ] {
            assert!(
                PHASE_PROBE_JS.to_lowercase().contains(needle),
                "PHASE_PROBE_JS must reference: {needle}"
            );
        }
    }
}