fix(stealth): split minimal/full mode to eliminate detection lies on real Chrome
- CdpAttach mode: only removes navigator.webdriver (user's real Chrome already has genuine fingerprint, heavy patches create detectable lies) - FullLaunch mode: applies all 32 patches (new Chrome needs full coverage) - Improved webdriver removal: uses Object.defineProperty to override CDP getter on Navigator.prototype, not just delete - CreepJS results: 0% stealth (was 20%), hasIframeProxy: gone Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7ee3d5fb94
commit
81cdd3b216
@@ -1729,6 +1729,8 @@ fn chrome_relaunch_hint() -> &'static str {
|
||||
}
|
||||
|
||||
/// Called after every successful launch / CDP connect / auto-connect.
|
||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
||||
return; // Explicitly disabled
|
||||
@@ -1739,10 +1741,21 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
let Ok(session_id) = mgr.active_session_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
||||
let mode = if mgr.is_cdp_connection() {
|
||||
stealth::StealthMode::CdpAttach
|
||||
} else {
|
||||
stealth::StealthMode::FullLaunch
|
||||
};
|
||||
|
||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||
if let Err(e) = stealth::apply_stealth(
|
||||
&mgr.client,
|
||||
session_id,
|
||||
mode,
|
||||
locale.as_deref(),
|
||||
)
|
||||
.await
|
||||
@@ -1751,7 +1764,7 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
}
|
||||
// Also inject into the current page (already loaded before our init script)
|
||||
if let Err(e) =
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, locale.as_deref()).await
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
|
||||
{
|
||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
||||
}
|
||||
|
||||
+70
-33
@@ -8,11 +8,33 @@ use serde_json::json;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Default stealth JS payload compiled at build time.
|
||||
/// The first line is a config placeholder that `build_stealth_script` replaces
|
||||
/// at runtime with the actual locale/language settings.
|
||||
/// Full stealth JS payload compiled at build time (for --launch mode).
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
|
||||
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
|
||||
const MINIMAL_STEALTH_SCRIPT: &str = r#"
|
||||
(function(){
|
||||
// CDP sets a getter on Navigator.prototype.webdriver that returns true.
|
||||
// Simple `delete` won't remove it. We must redefine the property with
|
||||
// Object.defineProperty to fully hide it from `'webdriver' in navigator`.
|
||||
const targets = [Navigator.prototype];
|
||||
if (typeof WorkerNavigator !== 'undefined') targets.push(WorkerNavigator.prototype);
|
||||
targets.push(Object.getPrototypeOf(navigator));
|
||||
for (const target of targets) {
|
||||
if (!target) continue;
|
||||
try { delete target.webdriver; } catch {}
|
||||
try {
|
||||
Object.defineProperty(target, 'webdriver', {
|
||||
get: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
delete target.webdriver;
|
||||
} catch {}
|
||||
}
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
@@ -20,10 +42,23 @@ pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--use-angle=default",
|
||||
];
|
||||
|
||||
/// Build the stealth JS payload with the given locale.
|
||||
/// Replaces the default `__abStealth` config line with one reflecting the
|
||||
/// actual browser locale so that `navigator.language` patches are consistent.
|
||||
pub fn build_stealth_script(locale: Option<&str>) -> String {
|
||||
/// Connection mode determines which stealth patches to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StealthMode {
|
||||
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
|
||||
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
|
||||
CdpAttach,
|
||||
/// Launched a new Chrome instance — apply full stealth patches.
|
||||
FullLaunch,
|
||||
}
|
||||
|
||||
/// Build the stealth JS payload for the given mode and locale.
|
||||
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
if mode == StealthMode::CdpAttach {
|
||||
return MINIMAL_STEALTH_SCRIPT.to_string();
|
||||
}
|
||||
|
||||
// Full launch mode: inject all patches
|
||||
let locale = locale.unwrap_or("en-US");
|
||||
let base_lang = locale.split('-').next().unwrap_or(locale);
|
||||
let languages: Vec<&str> = if base_lang == locale {
|
||||
@@ -37,29 +72,28 @@ pub fn build_stealth_script(locale: Option<&str>) -> String {
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
);
|
||||
|
||||
// Replace the placeholder first line
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
// Fallback: prepend config and include everything
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session:
|
||||
/// 1. Inject init script (runs before any page JS on every navigation)
|
||||
/// 2. Override User-Agent via CDP to remove HeadlessChrome markers
|
||||
/// 3. Override navigator.userAgentData high-entropy hints
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(locale);
|
||||
let script = build_stealth_script(mode, locale);
|
||||
|
||||
// 1. Inject stealth scripts to run before page JS
|
||||
// Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
@@ -68,23 +102,25 @@ pub async fn apply_stealth(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. Detect current User-Agent and clean up HeadlessChrome marker
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
// In full launch mode, also override UA to remove HeadlessChrome marker
|
||||
if mode == StealthMode::FullLaunch {
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +148,10 @@ async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<
|
||||
pub async fn apply_stealth_to_current_page(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(locale);
|
||||
let script = build_stealth_script(mode, locale);
|
||||
client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
|
||||
Reference in New Issue
Block a user