Stealth profile consistency (#269)

* feat(stealth): randomize browser profile per session

Add a pool of 8 realistic browser profiles (Win10/Win11/macOS, Chrome
143-146). One is picked randomly per BrowserContext instead of always
advertising Linux, which is a strong bot signal.

Each profile keeps navigator.platform, userAgentData.platform, and
userAgentData.platformVersion internally consistent with the UA string.

- profiles.rs: static PROFILES pool + random_profile() picker
- context.rs: pick profile on construction, store platform fields
- page.rs: call rt.set_platform() alongside rt.set_user_agent()
- runtime.rs: add set_platform() to inject __obscura_platform globals
- bootstrap.js: read platform/userAgentData from globals (default Win32)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(stealth): match GPU renderer pool to OS profile

Windows profiles get ANGLE Direct3D11 renderers; macOS profiles get
ANGLE Metal renderers. Vendor strings updated to match (Google Inc. (Apple)
for Apple Silicon/Intel Mac, Google Inc. (NVIDIA/AMD/Intel) for Windows).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(stealth): navigator.webdriver returns false instead of undefined

Browsers set navigator.webdriver to false when automation is not active.
Returning undefined is a detectable bot signal — fingerprinting scripts
check typeof navigator.webdriver === 'boolean' as part of headless detection.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Make stealth profile stable by default, rotation opt-in

Picking a random profile per BrowserContext meant one address would cycle
through different browser identities, which is itself a bot signal, and the
rotated profile carries no matching TLS or timezone fingerprint. Default to a
single stable profile; opt into rotation with OBSCURA_ROTATE_PROFILE, or pin
one with OBSCURA_PROFILE=<index>.

Drive timezone from the process TZ so Intl.DateTimeFormat and Date report one
consistent zone. Default Europe/Berlin; override with OBSCURA_TIMEZONE.

Update navigator.webdriver test to expect false, matching real Chrome.

---------

Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
johnnogueiraandSGavrl authored and GitHub committed 2026-06-08 21:23:50 +00:00
1 parent f20626bdbd
commit a23dad7cd9
7 files changed
+171 -26

No files matched your search

+11 -3
View File
@@ -8,6 +8,9 @@ pub struct BrowserContext {
pub cookie_jar: Arc<CookieJar>, pub cookie_jar: Arc<CookieJar>,
pub http_client: Arc<ObscuraHttpClient>, pub http_client: Arc<ObscuraHttpClient>,
pub user_agent: String, pub user_agent: String,
pub platform: String,
pub ua_platform: String,
pub ua_platform_version: String,
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
pub robots_cache: Arc<RobotsCache>, pub robots_cache: Arc<RobotsCache>,
pub obey_robots: bool, pub obey_robots: bool,
@@ -103,9 +106,11 @@ impl BrowserContext {
if stealth { if stealth {
client.block_trackers = true; client.block_trackers = true;
} }
let resolved_ua = user_agent.unwrap_or_else(|| { let profile = crate::profiles::select_profile();
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string() 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 // 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 // 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. // try_write always succeeds; we fall back silently if it ever fails.
@@ -118,6 +123,9 @@ impl BrowserContext {
cookie_jar, cookie_jar,
http_client, http_client,
user_agent: resolved_ua, user_agent: resolved_ua,
platform,
ua_platform,
ua_platform_version,
proxy_url, proxy_url,
robots_cache: Arc::new(RobotsCache::new()), robots_cache: Arc::new(RobotsCache::new()),
obey_robots: false, obey_robots: false,
+1
View File
@@ -1,6 +1,7 @@
pub mod page; pub mod page;
pub mod context; pub mod context;
pub mod lifecycle; pub mod lifecycle;
pub mod profiles;
pub use page::{NetworkEvent, Page, PageError}; pub use page::{NetworkEvent, Page, PageError};
pub use context::BrowserContext; pub use context::BrowserContext;
+5
View File
@@ -267,6 +267,11 @@ impl Page {
if let Ok(ua) = self.http_client.user_agent.try_read() { if let Ok(ua) = self.http_client.user_agent.try_read() {
rt.set_user_agent(&ua); 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_cookie_jar(self.context.cookie_jar.clone());
rt.set_http_client(self.http_client.clone()); rt.set_http_client(self.http_client.clone());
+103
View File
@@ -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=<index> 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::<usize>().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")
)
}
+14
View File
@@ -269,6 +269,20 @@ fn effective_v8_flags(user: Option<&str>) -> String {
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args = Args::parse(); 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 quiet = is_quiet_command(&args.command);
let filter = select_log_filter(args.verbose, quiet); let filter = select_log_filter(args.verbose, quiet);
tracing_subscriber::fmt() tracing_subscriber::fmt()
+23 -22
View File
@@ -72,7 +72,15 @@ function _fpNoise(x, y, channel) {
var _fpCache = null; var _fpCache = null;
function _getFp() { function _getFp() {
if (_fpCache) return _fpCache; 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 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 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)', '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 (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)', '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. (NVIDIA)','Google Inc. (NVIDIA)','Google Inc. (NVIDIA)',
'Google Inc. (Intel)','Google Inc. (Intel)', 'Google Inc. (Intel)','Google Inc. (Intel)',
'Google Inc. (AMD)','Google Inc. (AMD)', 'Google Inc. (AMD)','Google Inc. (AMD)',
@@ -2354,14 +2366,14 @@ function _registerIframe(iframeEl) {
globalThis.navigator = { 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 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/', ''); }, 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, onLine: true, cookieEnabled: true, hardwareConcurrency: 8,
maxTouchPoints: 0, maxTouchPoints: 0,
vendor: "Google Inc.", product: "Gecko", productSub: "20030107", vendor: "Google Inc.", product: "Gecko", productSub: "20030107",
doNotTrack: null, doNotTrack: null,
deviceMemory: 8, deviceMemory: 8,
connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false }, connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false },
get webdriver() { return undefined; }, get webdriver() { return false; },
pdfViewerEnabled: true, pdfViewerEnabled: true,
get plugins() { get plugins() {
const p = [ const p = [
@@ -2393,7 +2405,7 @@ globalThis.navigator = {
{brand: "Not=A?Brand", version: "24"}, {brand: "Not=A?Brand", version: "24"},
], ],
mobile: false, mobile: false,
platform: "Linux", get platform() { return globalThis.__obscura_ua_platform || "Windows"; },
getHighEntropyValues(hints) { getHighEntropyValues(hints) {
return Promise.resolve({ return Promise.resolve({
architecture: "x86", 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"}], 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, mobile: false,
model: "", model: "",
platform: "Linux", platform: globalThis.__obscura_ua_platform || "Windows",
platformVersion: "6.8.0", platformVersion: globalThis.__obscura_ua_platform_version || "15.0.0",
uaFullVersion: "145.0.0.0", uaFullVersion: "145.0.0.0",
}); });
}, },
@@ -5509,21 +5521,10 @@ _markNative(SpeechSynthesisUtterance);
_markNative(MediaStream); _markNative(MediaStreamTrack); _markNative(MediaStream); _markNative(MediaStreamTrack);
_markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate); _markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate);
const _OrigDateTimeFormat = Intl.DateTimeFormat; // Timezone is driven by the process TZ (set by the CLI, default Europe/Berlin),
const _defaultTZ = 'Europe/Berlin'; // so native Intl.DateTimeFormat and Date report the same zone. No JS override:
Intl.DateTimeFormat = function(locales, options) { // forcing a fixed zone here only on Intl left Date on UTC, which is the exact
if (!options) options = {}; // cross-surface mismatch a fingerprinting script looks for.
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;
};
if (typeof PointerEvent === 'undefined') { if (typeof PointerEvent === 'undefined') {
globalThis.PointerEvent = class PointerEvent extends MouseEvent { globalThis.PointerEvent = class PointerEvent extends MouseEvent {
+14 -1
View File
@@ -206,6 +206,19 @@ impl ObscuraJsRuntime {
format!("globalThis.__obscura_ua = '{}';", escaped), 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(
"<set-platform>",
format!(
"globalThis.__obscura_platform='{}';globalThis.__obscura_ua_platform='{}';globalThis.__obscura_ua_platform_version='{}';",
p, uap, uapv
),
);
}
pub fn evaluate(&mut self, expression: &str) -> Result<serde_json::Value, String> { pub fn evaluate(&mut self, expression: &str) -> Result<serde_json::Value, String> {
let wrapped = Self::wrap_expression(expression); let wrapped = Self::wrap_expression(expression);
let result = self let result = self
@@ -1511,7 +1524,7 @@ mod tests {
let ua = rt.evaluate("navigator.userAgent").unwrap(); let ua = rt.evaluate("navigator.userAgent").unwrap();
assert!(ua.as_str().unwrap().contains("Chrome"), "UA should contain Chrome: {}", ua); assert!(ua.as_str().unwrap().contains("Chrome"), "UA should contain Chrome: {}", ua);
let wd = rt.evaluate("navigator.webdriver").unwrap(); 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(); let plugins = rt.evaluate("navigator.plugins.length").unwrap();
assert!(plugins.as_f64().unwrap() > 0.0, "Should have plugins"); assert!(plugins.as_f64().unwrap() > 0.0, "Should have plugins");
let chrome = rt.evaluate("typeof window.chrome").unwrap(); let chrome = rt.evaluate("typeof window.chrome").unwrap();