diff --git a/crates/obscura-browser/src/context.rs b/crates/obscura-browser/src/context.rs index f4509b4..9be8f86 100644 --- a/crates/obscura-browser/src/context.rs +++ b/crates/obscura-browser/src/context.rs @@ -8,6 +8,9 @@ pub struct BrowserContext { pub cookie_jar: Arc, pub http_client: Arc, pub user_agent: String, + pub platform: String, + pub ua_platform: String, + pub ua_platform_version: String, pub proxy_url: Option, pub robots_cache: Arc, pub obey_robots: bool, @@ -103,9 +106,11 @@ impl BrowserContext { if stealth { client.block_trackers = true; } - let resolved_ua = user_agent.unwrap_or_else(|| { - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string() - }); + let profile = crate::profiles::select_profile(); + let resolved_ua = user_agent.unwrap_or_else(|| profile.user_agent.to_string()); + let platform = profile.platform.to_string(); + let ua_platform = profile.ua_platform.to_string(); + let ua_platform_version = profile.ua_platform_version.to_string(); // Sync the http client's UA at construction so navigation requests pick it // up before any async setup runs. The lock has no other holders here, so // try_write always succeeds; we fall back silently if it ever fails. @@ -118,6 +123,9 @@ impl BrowserContext { cookie_jar, http_client, user_agent: resolved_ua, + platform, + ua_platform, + ua_platform_version, proxy_url, robots_cache: Arc::new(RobotsCache::new()), obey_robots: false, diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs index fd38ebf..376b816 100644 --- a/crates/obscura-browser/src/lib.rs +++ b/crates/obscura-browser/src/lib.rs @@ -1,6 +1,7 @@ pub mod page; pub mod context; pub mod lifecycle; +pub mod profiles; pub use page::{NetworkEvent, Page, PageError}; pub use context::BrowserContext; diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 9998370..3acce7f 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -267,6 +267,11 @@ impl Page { if let Ok(ua) = self.http_client.user_agent.try_read() { rt.set_user_agent(&ua); } + rt.set_platform( + &self.context.platform, + &self.context.ua_platform, + &self.context.ua_platform_version, + ); rt.set_cookie_jar(self.context.cookie_jar.clone()); rt.set_http_client(self.http_client.clone()); diff --git a/crates/obscura-browser/src/profiles.rs b/crates/obscura-browser/src/profiles.rs new file mode 100644 index 0000000..8008cbb --- /dev/null +++ b/crates/obscura-browser/src/profiles.rs @@ -0,0 +1,103 @@ +pub struct BrowserProfile { + pub user_agent: &'static str, + pub platform: &'static str, + pub ua_platform: &'static str, + pub ua_platform_version: &'static str, +} + +pub static PROFILES: &[BrowserProfile] = &[ + BrowserProfile { + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + platform: "Win32", + ua_platform: "Windows", + ua_platform_version: "10.0.0", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + platform: "Win32", + ua_platform: "Windows", + ua_platform_version: "10.0.0", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + platform: "Win32", + ua_platform: "Windows", + ua_platform_version: "15.0.0", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + platform: "Win32", + ua_platform: "Windows", + ua_platform_version: "15.0.0", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + platform: "MacIntel", + ua_platform: "macOS", + ua_platform_version: "13.6.7", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + platform: "MacIntel", + ua_platform: "macOS", + ua_platform_version: "14.4.1", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + platform: "MacIntel", + ua_platform: "macOS", + ua_platform_version: "14.5.0", + }, + BrowserProfile { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + platform: "MacIntel", + ua_platform: "macOS", + ua_platform_version: "14.6.0", + }, +]; + +pub fn random_profile() -> &'static BrowserProfile { + let idx = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as usize) + % PROFILES.len(); + &PROFILES[idx] +} + +/// Pick the profile for a new browser context. +/// +/// The default is a single stable profile. Cycling through different browser +/// identities from one address is itself a bot signal (a real address maps to a +/// stable device), and the rotated profile does not yet carry a matching TLS or +/// timezone fingerprint, so rotation is opt-in: +/// OBSCURA_PROFILE= pin a specific profile from PROFILES +/// OBSCURA_ROTATE_PROFILE=1 pick a random profile per context +pub fn select_profile() -> &'static BrowserProfile { + if let Some(idx) = std::env::var("OBSCURA_PROFILE") + .ok() + .as_deref() + .map(str::trim) + .and_then(|s| s.parse::().ok()) + { + if idx < PROFILES.len() { + return &PROFILES[idx]; + } + } + if env_enabled("OBSCURA_ROTATE_PROFILE") { + return random_profile(); + } + &PROFILES[0] +} + +fn env_enabled(key: &str) -> bool { + matches!( + std::env::var(key) + .ok() + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1") | Some("true") | Some("yes") | Some("on") + ) +} diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 4730538..e088053 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -269,6 +269,20 @@ fn effective_v8_flags(user: Option<&str>) -> String { async fn main() -> anyhow::Result<()> { let args = Args::parse(); + // Pin the process timezone before V8/ICU reads it. V8 sources the zone for + // both Date (getTimezoneOffset, toString) and Intl.DateTimeFormat from TZ; left + // unset it defaults to UTC for Date while the page layer advertised a different + // zone, a cross-surface mismatch fingerprinting scripts flag. Default to + // Europe/Berlin; set OBSCURA_TIMEZONE to match the exit IP's region. An existing + // TZ from the host is respected. + // SAFETY: runs before any V8 isolate or worker thread starts, so the env is + // effectively single threaded here. + if let Some(tz) = std::env::var("OBSCURA_TIMEZONE").ok().filter(|s| !s.trim().is_empty()) { + unsafe { std::env::set_var("TZ", tz); } + } else if std::env::var_os("TZ").is_none() { + unsafe { std::env::set_var("TZ", "Europe/Berlin"); } + } + let quiet = is_quiet_command(&args.command); let filter = select_log_filter(args.verbose, quiet); tracing_subscriber::fmt() diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index ea1c27c..006388d 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -72,7 +72,15 @@ function _fpNoise(x, y, channel) { var _fpCache = null; function _getFp() { if (_fpCache) return _fpCache; - const gpuPool = [ + const isMac = (globalThis.__obscura_ua_platform || 'Windows') === 'macOS'; + const gpuPool = isMac ? [ + 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)', + 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)', + 'ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)', + 'ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version)', + 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)', + 'ANGLE (Intel Inc., ANGLE Metal Renderer: Intel(R) Iris(TM) Plus Graphics, Unspecified Version)', + ] : [ 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)', 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)', 'ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)', @@ -86,7 +94,11 @@ function _getFp() { 'ANGLE (AMD, AMD Radeon RX 5700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)', 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)', ]; - const gpuVendorPool = [ + const gpuVendorPool = isMac ? [ + 'Google Inc. (Apple)','Google Inc. (Apple)','Google Inc. (Apple)', + 'Google Inc. (Apple)','Google Inc. (Apple)', + 'Google Inc. (Intel Inc.)', + ] : [ 'Google Inc. (NVIDIA)','Google Inc. (NVIDIA)','Google Inc. (NVIDIA)', 'Google Inc. (Intel)','Google Inc. (Intel)', 'Google Inc. (AMD)','Google Inc. (AMD)', @@ -2354,14 +2366,14 @@ function _registerIframe(iframeEl) { globalThis.navigator = { get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; }, get appVersion() { return this.userAgent.replace('Mozilla/', ''); }, - language: "en-US", languages: ["en-US","en"], platform: "Linux x86_64", + language: "en-US", languages: ["en-US","en"], get platform() { return globalThis.__obscura_platform || "Win32"; }, onLine: true, cookieEnabled: true, hardwareConcurrency: 8, maxTouchPoints: 0, vendor: "Google Inc.", product: "Gecko", productSub: "20030107", doNotTrack: null, deviceMemory: 8, connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false }, - get webdriver() { return undefined; }, + get webdriver() { return false; }, pdfViewerEnabled: true, get plugins() { const p = [ @@ -2393,7 +2405,7 @@ globalThis.navigator = { {brand: "Not=A?Brand", version: "24"}, ], mobile: false, - platform: "Linux", + get platform() { return globalThis.__obscura_ua_platform || "Windows"; }, getHighEntropyValues(hints) { return Promise.resolve({ architecture: "x86", @@ -2402,8 +2414,8 @@ globalThis.navigator = { fullVersionList: [{brand:"Google Chrome",version:"145.0.0.0"},{brand:"Chromium",version:"145.0.0.0"},{brand:"Not=A?Brand",version:"24.0.0.0"}], mobile: false, model: "", - platform: "Linux", - platformVersion: "6.8.0", + platform: globalThis.__obscura_ua_platform || "Windows", + platformVersion: globalThis.__obscura_ua_platform_version || "15.0.0", uaFullVersion: "145.0.0.0", }); }, @@ -5509,21 +5521,10 @@ _markNative(SpeechSynthesisUtterance); _markNative(MediaStream); _markNative(MediaStreamTrack); _markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate); -const _OrigDateTimeFormat = Intl.DateTimeFormat; -const _defaultTZ = 'Europe/Berlin'; -Intl.DateTimeFormat = function(locales, options) { - if (!options) options = {}; - if (!options.timeZone) options.timeZone = _defaultTZ; - return new _OrigDateTimeFormat(locales, options); -}; -Intl.DateTimeFormat.prototype = _OrigDateTimeFormat.prototype; -Intl.DateTimeFormat.supportedLocalesOf = _OrigDateTimeFormat.supportedLocalesOf; -const _origResolved = _OrigDateTimeFormat.prototype.resolvedOptions; -_OrigDateTimeFormat.prototype.resolvedOptions = function() { - const r = _origResolved.call(this); - if (r.timeZone === 'UTC') r.timeZone = _defaultTZ; - return r; -}; +// Timezone is driven by the process TZ (set by the CLI, default Europe/Berlin), +// so native Intl.DateTimeFormat and Date report the same zone. No JS override: +// forcing a fixed zone here only on Intl left Date on UTC, which is the exact +// cross-surface mismatch a fingerprinting script looks for. if (typeof PointerEvent === 'undefined') { globalThis.PointerEvent = class PointerEvent extends MouseEvent { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index ea8d5c2..ce89619 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -206,6 +206,19 @@ impl ObscuraJsRuntime { format!("globalThis.__obscura_ua = '{}';", escaped), ); } + + pub fn set_platform(&mut self, platform: &str, ua_platform: &str, ua_platform_version: &str) { + let p = platform.replace('\'', "\\'"); + let uap = ua_platform.replace('\'', "\\'"); + let uapv = ua_platform_version.replace('\'', "\\'"); + let _ = self.runtime.execute_script( + "", + format!( + "globalThis.__obscura_platform='{}';globalThis.__obscura_ua_platform='{}';globalThis.__obscura_ua_platform_version='{}';", + p, uap, uapv + ), + ); + } pub fn evaluate(&mut self, expression: &str) -> Result { let wrapped = Self::wrap_expression(expression); let result = self @@ -1511,7 +1524,7 @@ mod tests { let ua = rt.evaluate("navigator.userAgent").unwrap(); assert!(ua.as_str().unwrap().contains("Chrome"), "UA should contain Chrome: {}", ua); let wd = rt.evaluate("navigator.webdriver").unwrap(); - assert_eq!(wd, serde_json::Value::Null); + assert_eq!(wd, serde_json::json!(false)); let plugins = rt.evaluate("navigator.plugins.length").unwrap(); assert!(plugins.as_f64().unwrap() > 0.0, "Should have plugins"); let chrome = rt.evaluate("typeof window.chrome").unwrap();