use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;
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
}
}
const CF_CLEARANCE_MIN_LEN: usize = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CfClearance {
Missing,
Pending,
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
}
}
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 {
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(),
},
};
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" => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
"interactive" => {
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,
});
}
_ => {}
}
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() {
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);
assert_eq!(classify_cf_clearance(&"a".repeat(29)), CfClearance::Pending);
}
#[test]
fn cf_clearance_long_token_is_valid() {
let real = "aBcD".repeat(12); assert_eq!(classify_cf_clearance(&real), CfClearance::Valid);
let quoted = format!("\"{real}\"");
assert_eq!(classify_cf_clearance("ed), CfClearance::Valid);
}
#[test]
fn supports_custom_kinds_for_rule_routing() {
let s = CloudflareInterstitialSolver::new();
assert!(s.supports(&DetectedCaptcha::Custom("imperva_incapsula".into())));
assert!(s.supports(&DetectedCaptcha::Turnstile));
}
}