feat(cloudflare): cf-status preflight — skip re-solving when cf_clearance is still valid
Passing a Cloudflare challenge mints an HttpOnly cf_clearance cookie bound to IP+UA. 'chrome-use cf-status' (aliases cf/cloudflare-status/clearance) reports whether the active page is currently a CF challenge and whether a still-valid cf_clearance exists (read via CDP — HttpOnly is invisible to document.cookie), plus CF_VERIFIED_DEVICE trust, and a recommendation: proceed (already cleared, don't re-solve) / solve (challenge up, no clearance) / reissue (clearance present but page still blocks → IP/UA drifted). Lets an agent avoid re-solving what it already cleared — the persistence optimization. Pure helpers unit-tested; live -verified on a real cf_clearance.
This commit is contained in:
@@ -1078,6 +1078,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "stealth_status" }))
|
Ok(json!({ "id": id, "action": "stealth_status" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `cf-status` — Cloudflare challenge/clearance preflight: is the page
|
||||||
|
// currently a CF challenge, and is there a still-valid cf_clearance (the
|
||||||
|
// HttpOnly persistence cookie)? Lets an agent SKIP re-solving when already
|
||||||
|
// cleared, and know when it must solve. Persistence optimization.
|
||||||
|
"cf-status" | "cf" | "cloudflare-status" | "clearance" => {
|
||||||
|
Ok(json!({ "id": id, "action": "cf_status" }))
|
||||||
|
}
|
||||||
|
|
||||||
// === Close ===
|
// === Close ===
|
||||||
"close" | "quit" | "exit" => {
|
"close" | "quit" | "exit" => {
|
||||||
// `close <tab>` closes only that tab (and the output says "Tab
|
// `close <tab>` closes only that tab (and the output says "Tab
|
||||||
|
|||||||
@@ -1341,6 +1341,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"forward" => handle_forward(state).await,
|
"forward" => handle_forward(state).await,
|
||||||
"reload" => handle_reload(state).await,
|
"reload" => handle_reload(state).await,
|
||||||
"cookies_get" => handle_cookies_get(cmd, state).await,
|
"cookies_get" => handle_cookies_get(cmd, state).await,
|
||||||
|
"cf_status" => handle_cf_status(cmd, state).await,
|
||||||
"cookies_set" => handle_cookies_set(cmd, state).await,
|
"cookies_set" => handle_cookies_set(cmd, state).await,
|
||||||
"cookies_clear" => handle_cookies_clear(state).await,
|
"cookies_clear" => handle_cookies_clear(state).await,
|
||||||
"storage_get" => handle_storage_get(cmd, state).await,
|
"storage_get" => handle_storage_get(cmd, state).await,
|
||||||
@@ -4122,6 +4123,108 @@ async fn handle_cookies_clear(state: &DaemonState) -> Result<Value, String> {
|
|||||||
Ok(json!({ "cleared": true }))
|
Ok(json!({ "cleared": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect whether the active page is *currently* a Cloudflare challenge
|
||||||
|
// (the full-page "Just a moment…" / "正在进行安全验证" interstitial), so an agent
|
||||||
|
// knows whether it must solve or can proceed. Runs in the top frame main world.
|
||||||
|
const CF_CHALLENGE_JS: &str = r#"(function(){
|
||||||
|
var t = document.title || '';
|
||||||
|
var challenged =
|
||||||
|
/just a moment|attention required|checking (your|if)|verify you are human|正在进行安全验证|安全验证|请稍候|请完成|人机验证/i.test(t) ||
|
||||||
|
!!document.querySelector('#challenge-form, #challenge-running, #cf-challenge-running, [id^="cf-chl"], script[src*="/cdn-cgi/challenge-platform/"]');
|
||||||
|
var turnstile = !!document.querySelector('.cf-turnstile, [data-sitekey]');
|
||||||
|
return JSON.stringify({ title: t, challenged: challenged, turnstile: turnstile, readyState: document.readyState });
|
||||||
|
})()"#;
|
||||||
|
|
||||||
|
/// Recommendation for a Cloudflare-gated page, from the current challenge state
|
||||||
|
/// and whether a still-valid `cf_clearance` exists. Pure so it's unit-testable.
|
||||||
|
/// - not challenged → "proceed" (the page is cleared/loaded)
|
||||||
|
/// - challenged, valid cookie → "reissue" (clearance present but page still
|
||||||
|
/// blocks → it's stale or the IP/UA no longer matches what it was issued for)
|
||||||
|
/// - challenged, no cookie → "solve"
|
||||||
|
fn cf_recommendation(challenged: bool, clearance_valid: bool) -> &'static str {
|
||||||
|
if !challenged {
|
||||||
|
"proceed"
|
||||||
|
} else if clearance_valid {
|
||||||
|
"reissue"
|
||||||
|
} else {
|
||||||
|
"solve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cf_clearance` validity for a cookie's expiry (epoch seconds; <=0 = session
|
||||||
|
/// cookie, treated as non-expiring). Returns (present, expired). Pure.
|
||||||
|
fn clearance_state(expires: Option<f64>, now: f64) -> (bool, bool) {
|
||||||
|
match expires {
|
||||||
|
None => (false, false),
|
||||||
|
Some(e) if e <= 0.0 => (true, false), // session cookie: no expiry
|
||||||
|
Some(e) => (true, e < now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_cf_status(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
|
|
||||||
|
// 1. Is the page a Cloudflare challenge right now?
|
||||||
|
let probe_raw = mgr.evaluate(CF_CHALLENGE_JS, None).await.unwrap_or(Value::Null);
|
||||||
|
let probe = parse_json_string(probe_raw, "cf challenge probe").unwrap_or(Value::Null);
|
||||||
|
let challenged = probe
|
||||||
|
.get("challenged")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let turnstile = probe
|
||||||
|
.get("turnstile")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let title = probe
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 2. Persistence artifacts: cf_clearance (HttpOnly → must read via CDP, not
|
||||||
|
// document.cookie) + CF_VERIFIED_DEVICE. Scope to the current URL.
|
||||||
|
let urls = if url.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(vec![url.clone()])
|
||||||
|
};
|
||||||
|
let cookies = super::cookies::get_cookies(&mgr.client, &session_id, urls)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let clearance = cookies.iter().find(|c| c.name == "cf_clearance");
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let (present, expired) = clearance_state(clearance.map(|c| c.expires), now);
|
||||||
|
let expires_in = clearance
|
||||||
|
.filter(|_| present && !expired)
|
||||||
|
.map(|c| (c.expires - now).max(0.0) as i64);
|
||||||
|
let device_verified = cookies
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.name.starts_with("CF_VERIFIED_DEVICE"));
|
||||||
|
|
||||||
|
let clearance_valid = present && !expired;
|
||||||
|
let recommendation = cf_recommendation(challenged, clearance_valid);
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"url": url,
|
||||||
|
"title": title,
|
||||||
|
"challenged": challenged,
|
||||||
|
"turnstile": turnstile,
|
||||||
|
"clearance": {
|
||||||
|
"present": present,
|
||||||
|
"expired": expired,
|
||||||
|
"expiresIn": expires_in,
|
||||||
|
"httpOnly": clearance.map(|c| c.http_only).unwrap_or(false),
|
||||||
|
},
|
||||||
|
"deviceVerified": device_verified,
|
||||||
|
"recommendation": recommendation,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
@@ -9069,6 +9172,26 @@ mod tests {
|
|||||||
use crate::test_utils::EnvGuard;
|
use crate::test_utils::EnvGuard;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cf_recommendation() {
|
||||||
|
assert_eq!(cf_recommendation(false, false), "proceed");
|
||||||
|
assert_eq!(cf_recommendation(false, true), "proceed");
|
||||||
|
assert_eq!(cf_recommendation(true, false), "solve");
|
||||||
|
assert_eq!(cf_recommendation(true, true), "reissue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clearance_state() {
|
||||||
|
// no cookie
|
||||||
|
assert_eq!(clearance_state(None, 1000.0), (false, false));
|
||||||
|
// session cookie (expires <= 0) → present, never expired
|
||||||
|
assert_eq!(clearance_state(Some(-1.0), 1000.0), (true, false));
|
||||||
|
// valid: expiry in the future
|
||||||
|
assert_eq!(clearance_state(Some(2000.0), 1000.0), (true, false));
|
||||||
|
// expired: expiry in the past
|
||||||
|
assert_eq!(clearance_state(Some(500.0), 1000.0), (true, true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_url_glob_to_regex() {
|
fn test_url_glob_to_regex() {
|
||||||
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
|
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
|
||||||
|
|||||||
@@ -224,6 +224,40 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloudflare challenge/clearance preflight (`cf-status`). Checked early
|
||||||
|
// because its response carries `url`/`title`, which later generic
|
||||||
|
// renderers would otherwise swallow.
|
||||||
|
if action == Some("cf_status") {
|
||||||
|
let challenged = data.get("challenged").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let rec = data.get("recommendation").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let cl = data.get("clearance");
|
||||||
|
let present = cl.and_then(|c| c.get("present")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let expired = cl.and_then(|c| c.get("expired")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64());
|
||||||
|
let device = data.get("deviceVerified").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
let (icon, headline) = match rec {
|
||||||
|
"proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"),
|
||||||
|
"solve" => (color::warning_indicator().to_string(), "Cloudflare challenge active, no valid clearance — solve it"),
|
||||||
|
"reissue" => (color::warning_indicator().to_string(), "challenge active but a clearance cookie exists — stale (IP/UA changed?), re-solve"),
|
||||||
|
_ => (color::cyan("•").to_string(), "unknown"),
|
||||||
|
};
|
||||||
|
println!("{} {}", icon, headline);
|
||||||
|
println!(" challenged: {}", if challenged { "yes" } else { "no" });
|
||||||
|
let cl_desc = if !present {
|
||||||
|
"absent".to_string()
|
||||||
|
} else if expired {
|
||||||
|
"present but EXPIRED".to_string()
|
||||||
|
} else if let Some(s) = expires_in {
|
||||||
|
format!("valid, expires in {}m {}s", s / 60, s % 60)
|
||||||
|
} else {
|
||||||
|
"present (session)".to_string()
|
||||||
|
};
|
||||||
|
println!(" cf_clearance: {}", cl_desc);
|
||||||
|
println!(" device trusted: {}", if device { "yes (CF_VERIFIED_DEVICE)" } else { "no" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Dialog status response
|
// Dialog status response
|
||||||
if action == Some("dialog") {
|
if action == Some("dialog") {
|
||||||
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
|
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
|
||||||
@@ -3195,6 +3229,10 @@ Get Info: chrome-use get <what> [selector]
|
|||||||
Check State: chrome-use is <what> <selector>
|
Check State: chrome-use is <what> <selector>
|
||||||
visible, enabled, checked
|
visible, enabled, checked
|
||||||
|
|
||||||
|
Anti-bot: chrome-use stealth | cf-status
|
||||||
|
stealth stealth self-check (webdriver/UA/plugins + overrides)
|
||||||
|
cf-status Cloudflare challenge + cf_clearance preflight (skip re-solving)
|
||||||
|
|
||||||
Find Elements: chrome-use find <locator> <value> <action> [text]
|
Find Elements: chrome-use find <locator> <value> <action> [text]
|
||||||
role, text, label, placeholder, alt, title, testid, first, last, nth
|
role, text, label, placeholder, alt, title, testid, first, last, nth
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ cadence, and scroll/drag ease. Default `off`; a per-navigation detector
|
|||||||
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
|
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
|
||||||
on auto; force `human` only when you already know the target scores behaviour.
|
on auto; force `human` only when you already know the target scores behaviour.
|
||||||
|
|
||||||
|
**Cloudflare clearance — solve once, reuse.** Passing a Cloudflare challenge
|
||||||
|
mints a `cf_clearance` cookie (HttpOnly — invisible to `eval`/`document.cookie`;
|
||||||
|
read it via `chrome-use cookies`). It's bound to your **IP + User-Agent**: reuse
|
||||||
|
the same exit IP and UA and you skip the challenge until it expires. Driving the
|
||||||
|
user's real Chrome (relay) persists it natively; for isolated sessions,
|
||||||
|
`--session-name <name>` save/restores it. Before spending effort solving, run
|
||||||
|
`chrome-use cf-status` (aliases `cf`, `clearance`): it reports whether the page
|
||||||
|
is *currently* a Cloudflare challenge and whether a still-valid `cf_clearance`
|
||||||
|
exists, with a recommendation — `proceed` (already cleared, don't re-solve),
|
||||||
|
`solve` (challenge up, no clearance), or `reissue` (clearance present but page
|
||||||
|
still blocks → IP/UA drifted, re-solve). Use it as a preflight to avoid
|
||||||
|
re-solving what you already cleared.
|
||||||
|
|
||||||
## Two ways to drive a page — and when to drop to `eval`
|
## Two ways to drive a page — and when to drop to `eval`
|
||||||
|
|
||||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||||
|
|||||||
Reference in New Issue
Block a user