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:
leeguooooo
2026-04-04 02:03:12 +09:00
co-authored by Claude Opus 4.6
parent a821006cc0
commit 193467fdb7
2 changed files with 84 additions and 34 deletions
+14 -1
View File
@@ -1502,6 +1502,8 @@ fn chrome_relaunch_hint() -> &'static str {
} }
/// Called after every successful launch / CDP connect / auto-connect. /// 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) { async fn apply_stealth_to_browser(state: &DaemonState) {
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) { if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
return; // Explicitly disabled return; // Explicitly disabled
@@ -1512,10 +1514,21 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
let Ok(session_id) = mgr.active_session_id() else { let Ok(session_id) = mgr.active_session_id() else {
return; 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(); let locale = env::var("AGENT_BROWSER_LOCALE").ok();
if let Err(e) = stealth::apply_stealth( if let Err(e) = stealth::apply_stealth(
&mgr.client, &mgr.client,
session_id, session_id,
mode,
locale.as_deref(), locale.as_deref(),
) )
.await .await
@@ -1524,7 +1537,7 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
} }
// Also inject into the current page (already loaded before our init script) // Also inject into the current page (already loaded before our init script)
if let Err(e) = 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); eprintln!("[stealth] Failed to patch current page: {}", e);
} }
+70 -33
View File
@@ -8,11 +8,33 @@ use serde_json::json;
use super::cdp::client::CdpClient; use super::cdp::client::CdpClient;
/// Default stealth JS payload compiled at build time. /// Full stealth JS payload compiled at build time (for --launch mode).
/// The first line is a config placeholder that `build_stealth_script` replaces
/// at runtime with the actual locale/language settings.
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js"); 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. /// Chrome launch arguments that reduce automation fingerprint surface.
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[ pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
"--disable-blink-features=AutomationControlled", "--disable-blink-features=AutomationControlled",
@@ -20,10 +42,23 @@ pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
"--use-angle=default", "--use-angle=default",
]; ];
/// Build the stealth JS payload with the given locale. /// Connection mode determines which stealth patches to apply.
/// Replaces the default `__abStealth` config line with one reflecting the #[derive(Clone, Copy, PartialEq)]
/// actual browser locale so that `navigator.language` patches are consistent. pub enum StealthMode {
pub fn build_stealth_script(locale: Option<&str>) -> String { /// 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 locale = locale.unwrap_or("en-US");
let base_lang = locale.split('-').next().unwrap_or(locale); let base_lang = locale.split('-').next().unwrap_or(locale);
let languages: Vec<&str> = if base_lang == 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()), 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( if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#, r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
) { ) {
format!("{}{}", config_line, rest) format!("{}{}", config_line, rest)
} else { } else {
// Fallback: prepend config and include everything
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW) format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
} }
} }
/// Apply stealth patches to a browser session: /// 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 /// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
/// 3. Override navigator.userAgentData high-entropy hints /// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
pub async fn apply_stealth( pub async fn apply_stealth(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
mode: StealthMode,
locale: Option<&str>, locale: Option<&str>,
) -> Result<(), String> { ) -> 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 client
.send_command( .send_command(
"Page.addScriptToEvaluateOnNewDocument", "Page.addScriptToEvaluateOnNewDocument",
@@ -68,23 +102,25 @@ pub async fn apply_stealth(
) )
.await?; .await?;
// 2. Detect current User-Agent and clean up HeadlessChrome marker // In full launch mode, also override UA to remove HeadlessChrome marker
let ua = get_browser_user_agent(client, session_id).await; if mode == StealthMode::FullLaunch {
if let Some(ua) = ua { let ua = get_browser_user_agent(client, session_id).await;
let cleaned = ua.replace("HeadlessChrome", "Chrome"); if let Some(ua) = ua {
if cleaned != ua { let cleaned = ua.replace("HeadlessChrome", "Chrome");
client if cleaned != ua {
.send_command( client
"Emulation.setUserAgentOverride", .send_command(
Some(json!({ "Emulation.setUserAgentOverride",
"userAgent": cleaned, Some(json!({
"acceptLanguage": locale.unwrap_or("en-US"), "userAgent": cleaned,
"platform": platform_string(), "acceptLanguage": locale.unwrap_or("en-US"),
"userAgentMetadata": build_ua_metadata(&cleaned, locale), "platform": platform_string(),
})), "userAgentMetadata": build_ua_metadata(&cleaned, locale),
Some(session_id), })),
) Some(session_id),
.await?; )
.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( pub async fn apply_stealth_to_current_page(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
mode: StealthMode,
locale: Option<&str>, locale: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let script = build_stealth_script(locale); let script = build_stealth_script(mode, locale);
client client
.send_command( .send_command(
"Runtime.evaluate", "Runtime.evaluate",