feat(stealth): shrink detectable surface — lazy Runtime.enable, native timezone/WebRTC, opt-in canvas noise

Borrow anti-detection hardening from Scrapling/patchright, preferring native
CDP/Chrome overrides over JS lies:

- Runtime.enable is now opt-in via AGENT_BROWSER_CAPTURE_CONSOLE (default off).
  It was called on every session INCLUDING CdpAttach (the user's real Chrome),
  leaking the patchright/rebrowser "runtime" CDP signal and undermining the
  "real browser, no lies" guarantee. Runtime.evaluate/callFunctionOn and
  runIfWaitingForDebugger work without it; only console/error capture needs it.
  The console/errors commands now return a hint when capture is disabled.
- Timezone alignment via native Emulation.setTimezoneOverride, opt-in with
  AGENT_BROWSER_TIMEZONE=<IANA>|auto (FullLaunch only). Intl and Date both
  follow with no JS artifact.
- WebRTC IP-leak handling via the --force-webrtc-ip-handling-policy Chrome
  flag: auto disable_non_proxied_udp when a proxy is set (so the real IP can't
  leak past the proxy); AGENT_BROWSER_BLOCK_WEBRTC=1 hides the local IP when
  there is no proxy; =0 opts out.
- Opt-in canvas/audio fingerprint noise via AGENT_BROWSER_HIDE_CANVAS=1
  (FullLaunch only). Session-stable seed so reads stay consistent within a
  session while differing from the headless-stable hash.

Adds 5 unit tests; full suite 751 passed, 0 failed.
This commit is contained in:
leeguooooo
2026-06-04 13:28:34 +09:00
parent 900a5b5cde
commit 6b99d304b1
5 changed files with 392 additions and 11 deletions
+27 -2
View File
@@ -3690,13 +3690,38 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
state.event_tracker.clear_console();
Ok(json!({ "cleared": true }))
} else {
let result = state.event_tracker.get_console_json();
let mut result = state.event_tracker.get_console_json();
if !console_capture_active(state) {
if let Some(obj) = result.as_object_mut() {
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
}
}
Ok(result)
}
}
/// Whether the active browser session has the CDP `Runtime` domain enabled
/// (required to receive console/error events). OFF by default for stealth.
fn console_capture_active(state: &DaemonState) -> bool {
state
.browser
.as_ref()
.map(|b| b.capture_console)
.unwrap_or(false)
}
const CONSOLE_DISABLED_HINT: &str =
"console/error capture is disabled for stealth (Runtime.enable is a detectable CDP \
signal). Restart the session with AGENT_BROWSER_CAPTURE_CONSOLE=1 to capture page output.";
async fn handle_errors(state: &DaemonState) -> Result<Value, String> {
Ok(state.event_tracker.get_errors_json())
let mut result = state.event_tracker.get_errors_json();
if !console_capture_active(state) {
if let Some(obj) = result.as_object_mut() {
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
}
}
Ok(result)
}
async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
+33 -6
View File
@@ -305,6 +305,22 @@ pub struct BrowserManager {
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
visited_origins: HashSet<String>,
next_tab_id: u32,
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
/// (the patchright / rebrowser "runtime leak") — even when attached to the user's
/// real Chrome. Opt in via `AGENT_BROWSER_CAPTURE_CONSOLE=1` when you need the
/// `console` / `errors` commands to return page output.
pub capture_console: bool,
}
/// Whether console/error capture (and thus `Runtime.enable`) is opted into for this
/// daemon. Defaults to `false` so the common automation path leaves no Runtime-domain
/// fingerprint. Set `AGENT_BROWSER_CAPTURE_CONSOLE=1` (or `true`) to turn it on.
pub fn console_capture_enabled() -> bool {
std::env::var("AGENT_BROWSER_CAPTURE_CONSOLE")
.ok()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -413,6 +429,7 @@ impl BrowserManager {
ignore_https_errors,
visited_origins: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
manager.discover_and_attach_targets().await?;
manager
@@ -502,6 +519,7 @@ impl BrowserManager {
ignore_https_errors: false,
visited_origins: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
if direct_page {
@@ -629,9 +647,14 @@ impl BrowserManager {
self.client
.send_command_no_params("Page.enable", Some(session_id))
.await?;
self.client
.send_command_no_params("Runtime.enable", Some(session_id))
.await?;
// `Runtime.enable` leaves a detectable CDP signal (the patchright/rebrowser
// "runtime leak"), so only enable it when console/error capture is opted in.
// `Runtime.evaluate` / `Runtime.callFunctionOn` work fine without it.
if self.capture_console {
self.client
.send_command_no_params("Runtime.enable", Some(session_id))
.await?;
}
// Resume the target if it is paused waiting for the debugger.
// This is needed for real browser sessions (Chrome 144+) where targets
// are paused after attach until explicitly resumed. No-op otherwise.
@@ -665,9 +688,12 @@ impl BrowserManager {
self.client
.send_command_no_params("Page.enable", None)
.await?;
self.client
.send_command_no_params("Runtime.enable", None)
.await?;
// See `enable_domains`: `Runtime.enable` is a CDP fingerprint, gated on opt-in.
if self.capture_console {
self.client
.send_command_no_params("Runtime.enable", None)
.await?;
}
let _ = self
.client
.send_command_no_params("Runtime.runIfWaitingForDebugger", None)
@@ -1659,6 +1685,7 @@ async fn initialize_lightpanda_manager(
ignore_https_errors: false,
visited_origins: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
+69
View File
@@ -146,6 +146,30 @@ struct ChromeArgs {
temp_user_data_dir: Option<PathBuf>,
}
/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
let opt_in = std::env::var("AGENT_BROWSER_BLOCK_WEBRTC").ok();
let explicitly_off = opt_in
.as_deref()
.is_some_and(|v| v == "0" || v.eq_ignore_ascii_case("false"));
if explicitly_off {
return None;
}
let explicitly_on = opt_in
.as_deref()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
if has_proxy {
// Force all WebRTC UDP through the proxy so the real IP can't leak.
Some("disable_non_proxied_udp")
} else if explicitly_on {
// No proxy, but the user asked to hide the local network IP.
Some("default_public_interface_only")
} else {
None
}
}
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let mut args = vec![
"--remote-debugging-port=0".to_string(),
@@ -204,6 +228,20 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
args.push(format!("--proxy-bypass-list={}", bypass));
}
// WebRTC IP-leak handling. WebRTC enumerates ICE candidates that can expose
// the machine's real local/public IP even when HTTP traffic goes through a
// proxy — defeating the proxy. `--force-webrtc-ip-handling-policy` is a real
// Chrome privacy switch (no detectable JS lie), applied here for launched
// Chrome only (an attached real Chrome keeps the user's own flags).
// - proxy set -> `disable_non_proxied_udp`: force WebRTC through
// the proxy so the real IP can't leak.
// - AGENT_BROWSER_BLOCK_WEBRTC=1 (no proxy) -> `default_public_interface_only`:
// hide the local network IP (Brave/uBlock default).
// Opt out entirely with AGENT_BROWSER_BLOCK_WEBRTC=0.
if let Some(policy) = webrtc_ip_handling_policy(options.proxy.is_some()) {
args.push(format!("--force-webrtc-ip-handling-policy={}", policy));
}
let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
let expanded = expand_tilde(profile);
let dir = PathBuf::from(&expanded);
@@ -1343,6 +1381,37 @@ mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn webrtc_policy_forces_proxy_when_proxy_set() {
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
// Proxy set, no env: always force WebRTC through the proxy.
assert_eq!(
webrtc_ip_handling_policy(true),
Some("disable_non_proxied_udp")
);
// No proxy, no env: leave WebRTC at Chrome's default.
assert_eq!(webrtc_ip_handling_policy(false), None);
}
#[test]
fn webrtc_policy_opt_in_and_opt_out() {
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "1");
assert_eq!(
webrtc_ip_handling_policy(false),
Some("default_public_interface_only")
);
// Explicit opt-out wins even when a proxy is set.
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "0");
assert_eq!(webrtc_ip_handling_policy(true), None);
assert_eq!(webrtc_ip_handling_policy(false), None);
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
}
#[cfg(unix)]
fn spawn_noop_child() -> Child {
Command::new("/bin/sh")
+139 -2
View File
@@ -51,13 +51,18 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
vec![locale, base_lang]
};
let config_line = format!(
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
hide_canvas_enabled(),
canvas_noise_seed(),
);
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
// otherwise the fallback below prepends a SECOND `const __abStealth`
// declaration and the whole script dies with a redeclaration SyntaxError.
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, hideCanvas: false, canvasSeed: 0 };"#,
) {
format!("{}{}", config_line, rest)
} else {
@@ -65,6 +70,35 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
}
}
/// Whether canvas/audio fingerprint noise is opted into (FullLaunch only).
/// OFF by default: injecting noise is a deliberate "lie" that can itself be a
/// tell, so it's reserved for users who explicitly want it via
/// `AGENT_BROWSER_HIDE_CANVAS=1`.
fn hide_canvas_enabled() -> bool {
std::env::var("AGENT_BROWSER_HIDE_CANVAS")
.ok()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
/// device returns the same hash on repeated reads) but differs from the
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
fn canvas_noise_seed() -> u32 {
use std::sync::OnceLock;
static SEED: OnceLock<u32> = OnceLock::new();
*SEED.get_or_init(|| {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0x9e3779b9);
// mix the bits a little, then force non-zero
let mixed = nanos ^ nanos.rotate_left(13).wrapping_mul(2654435761);
mixed | 1
})
}
/// Apply stealth patches to a browser session.
///
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
@@ -118,11 +152,114 @@ pub async fn apply_stealth(
.await?;
}
}
// Align the timezone for fresh launches when explicitly requested.
// Headless/launched Chrome often reports UTC (or the host's zone), which
// can contradict a proxy's geolocation or a spoofed locale.
// `Emulation.setTimezoneOverride` is a NATIVE override — Intl.DateTimeFormat
// and Date both follow it with no detectable JS lie. Opt-in only:
// AGENT_BROWSER_TIMEZONE=<IANA id> -> use that zone (e.g. align to proxy)
// AGENT_BROWSER_TIMEZONE=auto -> derive a default from the locale
// (unset) -> leave the real timezone untouched
if let Some(tz) = resolve_timezone(locale) {
let _ = client
.send_command(
"Emulation.setTimezoneOverride",
Some(json!({ "timezoneId": tz })),
Some(session_id),
)
.await;
}
}
Ok(())
}
/// Resolve the timezone to emulate for a fresh-launch session, if any.
/// Controlled by `AGENT_BROWSER_TIMEZONE`: an explicit IANA id, or `auto` to
/// derive a sensible default from the locale. Returns `None` (leave the real
/// timezone) when unset, empty, or when `auto` can't map the locale.
fn resolve_timezone(locale: Option<&str>) -> Option<String> {
let raw = std::env::var("AGENT_BROWSER_TIMEZONE").ok()?;
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if raw.eq_ignore_ascii_case("auto") {
return locale
.and_then(locale_default_timezone)
.map(str::to_string);
}
Some(raw.to_string())
}
/// Best-effort IANA timezone for a locale. Used only for
/// `AGENT_BROWSER_TIMEZONE=auto`; unknown locales return `None` so the real
/// timezone is left untouched rather than guessing a wrong one.
#[cfg(test)]
mod timezone_tests {
use super::{locale_default_timezone, resolve_timezone};
#[test]
fn maps_common_locales_case_insensitively() {
assert_eq!(locale_default_timezone("en-US"), Some("America/New_York"));
assert_eq!(locale_default_timezone("ja-JP"), Some("Asia/Tokyo"));
assert_eq!(locale_default_timezone("zh-CN"), Some("Asia/Shanghai"));
assert_eq!(locale_default_timezone("ZH-TW"), Some("Asia/Taipei"));
assert_eq!(locale_default_timezone("ja"), Some("Asia/Tokyo"));
}
#[test]
fn unknown_locale_returns_none() {
assert_eq!(locale_default_timezone("xx-YY"), None);
assert_eq!(locale_default_timezone(""), None);
}
#[test]
fn resolve_timezone_honors_env() {
// Serialized via a single test to avoid cross-test env races on this key.
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
assert_eq!(resolve_timezone(Some("en-US")), None);
std::env::set_var("AGENT_BROWSER_TIMEZONE", "Europe/Berlin");
assert_eq!(resolve_timezone(None), Some("Europe/Berlin".to_string()));
std::env::set_var("AGENT_BROWSER_TIMEZONE", " ");
assert_eq!(resolve_timezone(Some("en-US")), None);
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
assert_eq!(resolve_timezone(Some("ja-JP")), Some("Asia/Tokyo".to_string()));
assert_eq!(resolve_timezone(Some("xx-YY")), None);
assert_eq!(resolve_timezone(None), None);
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
}
}
fn locale_default_timezone(locale: &str) -> Option<&'static str> {
let tz = match locale.to_ascii_lowercase().as_str() {
"en-us" => "America/New_York",
"en-ca" => "America/Toronto",
"en-gb" => "Europe/London",
"en-au" => "Australia/Sydney",
"ja" | "ja-jp" => "Asia/Tokyo",
"ko" | "ko-kr" => "Asia/Seoul",
"zh-cn" | "zh-hans" | "zh-hans-cn" => "Asia/Shanghai",
"zh-tw" | "zh-hant" | "zh-hant-tw" => "Asia/Taipei",
"zh-hk" => "Asia/Hong_Kong",
"de" | "de-de" => "Europe/Berlin",
"fr" | "fr-fr" => "Europe/Paris",
"es" | "es-es" => "Europe/Madrid",
"it" | "it-it" => "Europe/Rome",
"nl" | "nl-nl" => "Europe/Amsterdam",
"pt-br" => "America/Sao_Paulo",
"pt" | "pt-pt" => "Europe/Lisbon",
"ru" | "ru-ru" => "Europe/Moscow",
_ => return None,
};
Some(tz)
}
/// 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
+124 -1
View File
@@ -1,4 +1,4 @@
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
(function(){
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
@@ -1275,3 +1275,126 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
}
}
})();
// Canvas + audio fingerprint noise (OPT-IN, full-launch only).
// Headless Chrome produces a stable canvas/audio hash that trackers use as a
// device id. When __abStealth.hideCanvas is on we perturb readback APIs with a
// SESSION-STABLE, sub-perceptual amount of noise: repeated reads on this page
// return the same noised result (a real device is consistent too), but the
// hash differs from the headless default. Off by default — noise is itself a
// "lie", so it's reserved for users who explicitly enable it.
(function(){
if (!__abStealth || __abStealth.hideCanvas !== true) return;
// Deterministic PRNG keyed by the per-session seed plus a position, so the
// same pixel/sample is perturbed identically every read within the session.
const baseSeed = (__abStealth.canvasSeed >>> 0) || 0x9e3779b9;
const noiseAt = (n) => {
let t = (baseSeed ^ Math.imul(n | 0, 0x6d2b79f5)) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
// Make a wrapped function masquerade as the native one (toString + name).
const mask = (wrapped, native) => {
try {
Object.defineProperty(wrapped, 'name', {
value: native.name,
configurable: true,
});
Object.defineProperty(wrapped, 'toString', {
value: () => native.toString(),
configurable: true,
writable: true,
});
} catch {}
return wrapped;
};
// ---- Canvas 2D readback ---------------------------------------------------
const perturbImageData = (imageData) => {
const data = imageData && imageData.data;
if (!data || !data.length) return imageData;
for (let i = 0; i < data.length; i += 4) {
// Touch ~5% of pixels by +/-1 on each RGB channel; leave alpha alone.
if (noiseAt(i) < 0.05) {
const delta = noiseAt(i + 1) < 0.5 ? -1 : 1;
data[i] = Math.max(0, Math.min(255, data[i] + delta));
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + delta));
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + delta));
}
}
return imageData;
};
try {
const ctxProto = (typeof CanvasRenderingContext2D !== 'undefined')
? CanvasRenderingContext2D.prototype : null;
if (ctxProto && typeof ctxProto.getImageData === 'function') {
const nativeGetImageData = ctxProto.getImageData;
ctxProto.getImageData = mask(function(...args) {
return perturbImageData(nativeGetImageData.apply(this, args));
}, nativeGetImageData);
}
} catch {}
// For toDataURL/toBlob, draw the (already-rendered) canvas onto a scratch
// canvas, perturb its pixels, then encode that — so the export hash shifts
// without disturbing what the page sees on screen.
const exportNoised = (canvas) => {
try {
const w = canvas.width, h = canvas.height;
if (!w || !h) return null;
const scratch = document.createElement('canvas');
scratch.width = w; scratch.height = h;
const sctx = scratch.getContext('2d');
if (!sctx) return null;
sctx.drawImage(canvas, 0, 0);
const img = sctx.getImageData(0, 0, w, h);
perturbImageData(img);
sctx.putImageData(img, 0, 0);
return scratch;
} catch { return null; }
};
try {
const canvasProto = (typeof HTMLCanvasElement !== 'undefined')
? HTMLCanvasElement.prototype : null;
if (canvasProto && typeof canvasProto.toDataURL === 'function') {
const nativeToDataURL = canvasProto.toDataURL;
canvasProto.toDataURL = mask(function(...args) {
const scratch = exportNoised(this);
return nativeToDataURL.apply(scratch || this, args);
}, nativeToDataURL);
}
if (canvasProto && typeof canvasProto.toBlob === 'function') {
const nativeToBlob = canvasProto.toBlob;
canvasProto.toBlob = mask(function(cb, ...rest) {
const scratch = exportNoised(this);
return nativeToBlob.call(scratch || this, cb, ...rest);
}, nativeToBlob);
}
} catch {}
// ---- AudioBuffer readback -------------------------------------------------
// Perturb time-domain samples by a tiny, seed-stable amount so the audio
// fingerprint (sum/hash of channel data) shifts without audible effect.
try {
const audioProto = (typeof AudioBuffer !== 'undefined') ? AudioBuffer.prototype : null;
if (audioProto && typeof audioProto.getChannelData === 'function') {
const nativeGetChannelData = audioProto.getChannelData;
const seen = new WeakSet();
audioProto.getChannelData = mask(function(...args) {
const channel = nativeGetChannelData.apply(this, args);
// Only perturb once per buffer to keep reads consistent.
if (channel && !seen.has(channel)) {
seen.add(channel);
for (let i = 0; i < channel.length; i += 100) {
channel[i] = channel[i] + (noiseAt(i) - 0.5) * 1e-7;
}
}
return channel;
}, nativeGetChannelData);
}
} catch {}
})();