Key insight: ANY JS-level modification to navigator.webdriver is detectable by creepjs's lieProps system. The only undetectable approach is Emulation.setAutomationOverride at the CDP protocol level, which tells Chrome to natively return false for navigator.webdriver. In CdpAttach mode, we now inject ZERO JavaScript patches — the browser's real fingerprint is already perfect. Only the CDP protocol command is needed. CreepJS results now match manual Chrome exactly: - 0% headless (was 33%) - 0% stealth (unchanged) - 25% like headless (Chrome baseline, same as manual) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
7.9 KiB
Rust
238 lines
7.9 KiB
Rust
//! Stealth anti-detection module.
|
|
//!
|
|
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
|
|
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
|
|
//! headless or automated Chrome instances.
|
|
|
|
use serde_json::json;
|
|
|
|
use super::cdp::client::CdpClient;
|
|
|
|
/// 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.
|
|
/// Minimal stealth script for CDP-attach mode.
|
|
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
|
|
/// level, so no JS patching is needed in CdpAttach mode. An empty script
|
|
/// avoids creating any detectable lie-props artifacts.
|
|
const MINIMAL_STEALTH_SCRIPT: &str = "";
|
|
|
|
/// Chrome launch arguments that reduce automation fingerprint surface.
|
|
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--use-gl=angle",
|
|
"--use-angle=default",
|
|
];
|
|
|
|
/// 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 {
|
|
vec![locale]
|
|
} else {
|
|
vec![locale, base_lang]
|
|
};
|
|
let config_line = format!(
|
|
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
|
locale,
|
|
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
|
);
|
|
|
|
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 {
|
|
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
// First: disable the automation flag at the CDP protocol level.
|
|
// This tells Chrome to natively set navigator.webdriver = false,
|
|
// which is undetectable by lie-detection systems like CreepJS.
|
|
// Falls back gracefully on older Chrome versions that don't support this.
|
|
let _ = client
|
|
.send_command(
|
|
"Emulation.setAutomationOverride",
|
|
Some(json!({ "enabled": false })),
|
|
Some(session_id),
|
|
)
|
|
.await;
|
|
|
|
let script = build_stealth_script(mode, locale);
|
|
|
|
// Inject stealth scripts to run before page JS
|
|
client
|
|
.send_command(
|
|
"Page.addScriptToEvaluateOnNewDocument",
|
|
Some(json!({ "source": script })),
|
|
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?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the browser's User-Agent string via CDP.
|
|
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
|
let result = client
|
|
.send_command(
|
|
"Runtime.evaluate",
|
|
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
|
|
Some(session_id),
|
|
)
|
|
.await
|
|
.ok()?;
|
|
result
|
|
.get("result")
|
|
.and_then(|r| r.get("value"))
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from)
|
|
}
|
|
|
|
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
|
|
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(mode, locale);
|
|
client
|
|
.send_command(
|
|
"Runtime.evaluate",
|
|
Some(json!({
|
|
"expression": script,
|
|
"returnByValue": true,
|
|
})),
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Strip sourceURL comments from CDP expressions to avoid leaking
|
|
/// automation-framework identifiers in stack traces.
|
|
pub fn strip_source_url_labels(input: &str) -> String {
|
|
// Remove //# sourceURL=... and //@ sourceURL=...
|
|
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
|
let output = re_line.replace_all(input, "");
|
|
// Remove /*# sourceURL=...*/ block comments
|
|
let re_block =
|
|
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
|
re_block.replace_all(&output, "").to_string()
|
|
}
|
|
|
|
fn platform_string() -> &'static str {
|
|
if cfg!(target_os = "macos") {
|
|
"macOS"
|
|
} else if cfg!(target_os = "windows") {
|
|
"Win32"
|
|
} else {
|
|
"Linux"
|
|
}
|
|
}
|
|
|
|
fn platform_hint() -> &'static str {
|
|
if cfg!(target_os = "macos") {
|
|
"macOS"
|
|
} else if cfg!(target_os = "windows") {
|
|
"Windows"
|
|
} else {
|
|
"Linux"
|
|
}
|
|
}
|
|
|
|
fn platform_version_hint() -> &'static str {
|
|
if cfg!(target_os = "macos") {
|
|
"14.0.0"
|
|
} else if cfg!(target_os = "windows") {
|
|
"10.0.0"
|
|
} else {
|
|
"6.5.0"
|
|
}
|
|
}
|
|
|
|
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
|
// Extract Chrome version from UA string
|
|
let chrome_version = ua
|
|
.split("Chrome/")
|
|
.nth(1)
|
|
.and_then(|s| s.split_whitespace().next())
|
|
.unwrap_or("130.0.0.0");
|
|
let major = chrome_version.split('.').next().unwrap_or("130");
|
|
|
|
let _lang = locale.unwrap_or("en-US");
|
|
|
|
json!({
|
|
"brands": [
|
|
{ "brand": "Chromium", "version": major },
|
|
{ "brand": "Google Chrome", "version": major },
|
|
{ "brand": "Not?A_Brand", "version": "99" },
|
|
],
|
|
"fullVersionList": [
|
|
{ "brand": "Chromium", "version": chrome_version },
|
|
{ "brand": "Google Chrome", "version": chrome_version },
|
|
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
|
|
],
|
|
"fullVersion": chrome_version,
|
|
"platform": platform_hint(),
|
|
"platformVersion": platform_version_hint(),
|
|
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
|
|
"model": "",
|
|
"mobile": false,
|
|
"bitness": "64",
|
|
"wow64": false,
|
|
})
|
|
}
|