feat(stealth): inject anti-detection patches in native Rust architecture
- Created cli/src/native/stealth.rs with stealth JS injection via CDP - Extracted 32 patch IIFEs from TS stealth.ts into stealth_scripts.js - Injected via Page.addScriptToEvaluateOnNewDocument on every launch/connect - Added stealth Chrome args (disable AutomationControlled, use ANGLE GL) - Auto-detects and cleans HeadlessChrome from User-Agent string - Overrides navigator.userAgentData high-entropy hints - Stealth enabled by default, disable with AGENT_BROWSER_STEALTH=0 Track 2 of native-stealth migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6addc80aa1
commit
77616a209c
Generated
+7
@@ -58,6 +58,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"image",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
@@ -1691,6 +1692,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
|
||||
@@ -17,6 +17,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
|
||||
@@ -33,6 +33,7 @@ use super::recording::{self, RecordingState};
|
||||
use super::screenshot::{self, ScreenshotOptions};
|
||||
use super::snapshot::{self, SnapshotOptions};
|
||||
use super::state;
|
||||
use super::stealth;
|
||||
use super::storage;
|
||||
use super::stream::{self, StreamServer};
|
||||
use super::tracing::{self as native_tracing, TracingState};
|
||||
@@ -1550,6 +1551,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
apply_stealth_to_browser(state).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1563,6 +1565,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
apply_stealth_to_browser(state).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1633,6 +1636,8 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
// Apply stealth anti-detection patches after browser is ready
|
||||
apply_stealth_to_browser(state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1683,6 +1688,36 @@ async fn apply_launch_init_scripts(state: &DaemonState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject stealth scripts into the active browser session.
|
||||
/// Called after every successful launch / CDP connect / auto-connect.
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
||||
return; // Explicitly disabled
|
||||
}
|
||||
let Some(ref mgr) = state.browser else {
|
||||
return;
|
||||
};
|
||||
let Ok(session_id) = mgr.active_session_id() else {
|
||||
return;
|
||||
};
|
||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||
if let Err(e) = stealth::apply_stealth(
|
||||
&mgr.client,
|
||||
session_id,
|
||||
locale.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
||||
}
|
||||
// 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
|
||||
{
|
||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_options_from_env() -> LaunchOptions {
|
||||
let headed = env::var("AGENT_BROWSER_HEADED")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
|
||||
@@ -147,6 +147,10 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
// Stealth: reduce automation fingerprint surface
|
||||
"--disable-blink-features=AutomationControlled".to_string(),
|
||||
"--use-gl=angle".to_string(),
|
||||
"--use-angle=default".to_string(),
|
||||
"--disable-background-networking".to_string(),
|
||||
"--disable-backgrounding-occluded-windows".to_string(),
|
||||
"--disable-component-update".to_string(),
|
||||
|
||||
@@ -35,6 +35,8 @@ pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod stealth;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 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;
|
||||
|
||||
/// 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.
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--use-gl=angle",
|
||||
"--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 {
|
||||
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()),
|
||||
);
|
||||
|
||||
// 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
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(locale);
|
||||
|
||||
// 1. Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.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?;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user