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
//! 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
    }
}

/// Minimum length for a `cf_clearance` value to count as a real clearance
/// token. Genuine `cf_clearance` cookies are long opaque strings (well over
/// 40 chars); a short/empty value is a placeholder set before the challenge
/// resolves.
const CF_CLEARANCE_MIN_LEN: usize = 30;

/// Validity classification of a `cf_clearance` cookie value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CfClearance {
    /// Cookie not present.
    Missing,
    /// Present but too short to be a real clearance token (placeholder).
    Pending,
    /// Long-form clearance token: Cloudflare accepted the session.
    Valid,
}

/// Pure classifier for the `cf_clearance` cookie value. A valid clearance is
/// the authoritative "we passed" signal for every Cloudflare variant (IUAM JS
/// challenge, managed challenge, Turnstile-backed interstitial), regardless of
/// what page phase the DOM probe reports.
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::cloudflare_interstitial::{classify_cf_clearance, CfClearance};
///
/// assert_eq!(classify_cf_clearance(""), CfClearance::Missing);
/// assert_eq!(classify_cf_clearance("short"), CfClearance::Pending);
/// let real = "aBcD".repeat(12); // 48 chars
/// assert_eq!(classify_cf_clearance(&real), CfClearance::Valid);
/// ```
pub fn classify_cf_clearance(value: &str) -> CfClearance {
    let v = value.trim().trim_matches('"');
    if v.is_empty() {
        return CfClearance::Missing;
    }
    if v.len() >= CF_CLEARANCE_MIN_LEN {
        CfClearance::Valid
    } else {
        CfClearance::Pending
    }
}

/// JS that probes the page for Cloudflare state across all variants and returns
/// the `cf_clearance` cookie value for [`classify_cf_clearance`]. Phases:
///   - `{ phase: "blocked" }`: hard block (error 1020/1015/1010/1006/…, or the
///      "you have been blocked" page); no amount of waiting helps.
///   - `{ phase: "interactive" }`: a `challenges.cloudflare.com` Turnstile
///      widget is embedded in the challenge (managed challenge needing a click);
///      yield to the interactive solver.
///   - `{ phase: "challenge" }`: IUAM / managed JS challenge running (title,
///      `#cf-challenge-running`, `window._cf_chl_opt`, or a
///      `/cdn-cgi/challenge-platform/` script).
///   - `{ phase: "passed" }`: no challenge markers; cookie decides.
/// Every phase carries `clearance` (the raw cookie value).
const PHASE_PROBE_JS: &str = r#"
(() => {
    const title = (document.title || '').toLowerCase();
    const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
    const html = (document.documentElement ? document.documentElement.outerHTML : '').toLowerCase();

    let clr = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        if (t.indexOf('cf_clearance=') === 0) { clr = t.slice('cf_clearance='.length); break; }
    }

    /* Hard blocks: CF numeric error codes + the explicit block page. These are
       terminal: distinct from the (waitable) JS challenge. */
    const blockCodes = ['1020', '1015', '1010', '1006', '1007', '1009', '1012', '1013'];
    const blocked =
        title.includes('access denied') ||
        body.includes('you have been blocked') ||
        body.includes('sorry, you have been blocked') ||
        blockCodes.some(c => body.includes('error ' + c) || html.includes('cloudflare ray id') && body.includes('error ' + c));
    if (blocked) return { phase: 'blocked', clearance: clr };

    let chlOpt = false;
    try { chlOpt = typeof window._cf_chl_opt !== 'undefined'; } catch (e) { chlOpt = false; }
    const challengeDom = !!document.querySelector(
        '#challenge-running, #cf-challenge-running, #challenge-form, .cf-browser-verification, #cf-please-wait, #trk_jschal_js, #challenge-stage'
    );
    const challengePlatform = !!document.querySelector('script[src*="/cdn-cgi/challenge-platform/"]');
    const onChallenge =
        title.includes('just a moment') ||
        title.includes('attention required') ||
        title.includes('checking your browser') ||
        chlOpt || challengeDom || challengePlatform;

    if (onChallenge) {
        /* A managed challenge embeds a Turnstile widget that may require an
           interactive click, signal so the chain can hand off. */
        const turnstile = !!document.querySelector('iframe[src*="challenges.cloudflare.com"]');
        return { phase: turnstile ? 'interactive' : 'challenge', clearance: clr };
    }
    return { phase: 'passed', clearance: clr };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    clearance: String,
}

#[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(),
                    clearance: String::new(),
                },
            };

            // A valid cf_clearance token is the authoritative success signal for
            // EVERY Cloudflare variant, return it regardless of phase (the DOM
            // can briefly still show challenge markers after the cookie lands).
            if classify_cf_clearance(&probe.clearance) == CfClearance::Valid {
                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,
                });
            }

            match probe.phase.as_str() {
                "blocked" => {
                    // Cloudflare explicitly rejected the visitor (error 1020 etc.).
                    // No amount of waiting will help.
                    return Ok(CaptchaSolveResult::failure(
                        SolveMethod::AutoPass,
                        t0.elapsed().as_millis() as u64,
                    ));
                }
                "interactive" => {
                    // Managed challenge with an embedded Turnstile widget that
                    // needs a click. Yield (screenshot + failure) so the chain
                    // advances to TurnstileInteractiveSolver / BehavioralSolver.
                    let shot = super::super::screenshot_b64(page).await.ok();
                    return Ok(CaptchaSolveResult {
                        solution: String::new(),
                        confidence: 0.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: false,
                        screenshot: shot,
                        cookies: Vec::new(),
                        verified_outcome: None,
                    });
                }
                // "challenge" (JS challenge running) and "passed" (cookie not yet
                // a valid token) both keep waiting within the budget.
                _ => {}
            }

            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}"
            );
        }
    }

    #[test]
    fn phase_probe_js_covers_modern_cf_variants() {
        // The signals that distinguish the modern CF challenge family from the
        // legacy "Just a moment" page, without these, current CF challenges
        // (which often drop the legacy title) are missed entirely.
        for needle in [
            "_cf_chl_opt",
            "/cdn-cgi/challenge-platform/",
            "#cf-challenge-running",
            "challenges.cloudflare.com",
            "checking your browser",
            "you have been blocked",
            "error ",
        ] {
            assert!(
                PHASE_PROBE_JS
                    .to_lowercase()
                    .contains(&needle.to_lowercase()),
                "PHASE_PROBE_JS must reference modern CF signal: {needle}"
            );
        }
    }

    #[test]
    fn phase_probe_js_emits_all_phases() {
        for phase in ["blocked", "interactive", "challenge", "passed"] {
            assert!(
                PHASE_PROBE_JS.contains(&format!("'{phase}'")),
                "PHASE_PROBE_JS must emit phase: {phase}"
            );
        }
    }

    #[test]
    fn cf_clearance_empty_is_missing() {
        assert_eq!(classify_cf_clearance(""), CfClearance::Missing);
        assert_eq!(classify_cf_clearance("\"\""), CfClearance::Missing);
    }

    #[test]
    fn cf_clearance_short_is_pending() {
        assert_eq!(classify_cf_clearance("short"), CfClearance::Pending);
        // 29 chars (just under the threshold).
        assert_eq!(classify_cf_clearance(&"a".repeat(29)), CfClearance::Pending);
    }

    #[test]
    fn cf_clearance_long_token_is_valid() {
        let real = "aBcD".repeat(12); // 48 chars
        assert_eq!(classify_cf_clearance(&real), CfClearance::Valid);
        // Quoted form (some servers wrap values).
        let quoted = format!("\"{real}\"");
        assert_eq!(classify_cf_clearance(&quoted), CfClearance::Valid);
    }

    #[test]
    fn supports_custom_kinds_for_rule_routing() {
        // CF rule variants surface as Custom(...) too; the solver must accept
        // them so a rule that names it can route here.
        let s = CloudflareInterstitialSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("imperva_incapsula".into())));
        assert!(s.supports(&DetectedCaptcha::Turnstile));
    }
}