diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index d72974a..00b046d 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -3690,13 +3690,38 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result 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 { - 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 { diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index fcdf55f..729dae1 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -305,6 +305,22 @@ pub struct BrowserManager { /// Origins visited during this session, used by save_state to collect cross-origin localStorage. visited_origins: HashSet, 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 { diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index bb5394d..9480120 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -146,6 +146,30 @@ struct ChromeArgs { temp_user_data_dir: Option, } +/// 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 { let mut args = vec![ "--remote-debugging-port=0".to_string(), @@ -204,6 +228,20 @@ fn build_chrome_args(options: &LaunchOptions) -> Result { 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") diff --git a/cli/src/native/stealth.rs b/cli/src/native/stealth.rs index 17da4f2..6e2d5be 100644 --- a/cli/src/native/stealth.rs +++ b/cli/src/native/stealth.rs @@ -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 = 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= -> 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 { + 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 { let result = client diff --git a/cli/src/native/stealth_scripts.js b/cli/src/native/stealth_scripts.js index 7eb267b..e93539d 100644 --- a/cli/src/native/stealth_scripts.js +++ b/cli/src/native/stealth_scripts.js @@ -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 {} +})();