From ac75cf52a6278ea4b949a74f717a7cbb8ccda025 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:35:58 +0000 Subject: [PATCH 1/6] stealth: fix platform/UA mismatch and hide internal globals from Object.keys navigator.platform returned "Win32" while navigator.userAgent said Linux when using the stealth client. page.rs now calls set_platform("Linux x86_64", "Linux", "") alongside set_user_agent when the stealth client is active. bootstrap.js: pre-declare 71 internal _* globals as enumerable:false before var runs, so they don't show up in Object.keys(window) on real pages. --- crates/obscura-browser/src/page.rs | 30 +++++++++++++++------- crates/obscura-js/js/bootstrap.js | 41 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 457a3a5..06d788a 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -276,18 +276,30 @@ impl Page { #[cfg(feature = "stealth")] if self.stealth_client.is_some() { rt.set_user_agent(obscura_net::STEALTH_USER_AGENT); - } else if let Ok(ua) = self.http_client.user_agent.try_read() { - rt.set_user_agent(&ua); + // Match platform to the Linux UA so navigator.platform and + // userAgentData.platform are consistent with navigator.userAgent. + rt.set_platform("Linux x86_64", "Linux", ""); + } else { + 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, + ); } #[cfg(not(feature = "stealth"))] - if let Ok(ua) = self.http_client.user_agent.try_read() { - rt.set_user_agent(&ua); + { + 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_platform( - &self.context.platform, - &self.context.ua_platform, - &self.context.ua_platform_version, - ); if let Some((lat, lon)) = env_geolocation() { rt.set_geolocation(lat, lon); } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 1ba9463..fcd1c96 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1,5 +1,46 @@ "use strict"; +// Pre-declare all internal globals as non-enumerable so they are invisible +// to Object.keys(window) / for-in enumeration. Must run before any var +// declarations or property assignments below: once a property is defined +// with enumerable:false here, subsequent `var x = value` assignments will +// find the property already exists and only update the value, leaving the +// descriptor intact. Direct globalThis.x = value assignments also only +// update the value without touching enumerable when the property is +// writable:true and configurable:true. +(function _preHideInternals() { + var _names = [ + // runtime-set by Rust (runtime.rs / page.rs) + '__obscura_errors', '__obscura_init', '__obscura_hide_list', + '__obscura_objects', '__obscura_oid', '__obscura_ua', + '__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version', + '__documentReadyState__', '__currentUrl', + // internal helpers (var-declared throughout the file) + '__processDynScriptQueue', '_markNative', '_fpRand', '_fpNoise', + '_fpCache', '_getFp', '_fp', '_splitAsciiWhitespace', + '_getElementsByClassName', '_docEncoding', '_docIsUtf8', + '_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase', + '_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth', + '_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber', + '_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl', + '_resolveUrl', '_registerIframe', '_base64ToUint8Array', + '_bodyToUint8Array', '_arrayBufferFromBytes', + '_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp', + '_urlResolveOp', '_decodeBodyWithCharset', '_utf8DecodeBytes', + '_selectionFor', '_isConstructorCE', '_isValidCustomElementName', + '_blobPartToBytes', '_bytesToBinaryString', '_formEncode', '_hexv', + '_commonFonts', '_isXMLDocument', '_isValidPITarget', '_isHTMLEl', + '_nodeList', '_rngNodeLength', '_rngNodeIndex', '_rngSame', '_rngRoot', + '_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset', + '_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase', + '_makeListenerBox', + ]; + var _desc = { value: undefined, writable: true, enumerable: false, configurable: true }; + for (var _i = 0; _i < _names.length; _i++) { + try { Object.defineProperty(globalThis, _names[_i], _desc); } catch (_e) {} + } +})(); + globalThis.__obscura_errors = []; globalThis.addEventListener = globalThis.addEventListener || function(){}; From a59e756da43758de1d135e5249df586e8d161332 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:02:37 +0000 Subject: [PATCH 2/6] stealth: add PluginArray class, move webdriver to navigator proto PluginArray: real browsers expose a native PluginArray type. navigator.plugins was returning a plain Array, so `navigator.plugins instanceof PluginArray` and `navigator.plugins[0].toString()` (which should return [object Plugin]) were failing sannysoft-style checks. Adds a PluginArray constructor whose prototype inherits from Array.prototype, with Symbol.toStringTag='PluginArray'. Each plugin entry now carries Symbol.toStringTag='Plugin' so Object.prototype.toString returns [object Plugin]. webdriver: moved the webdriver getter off the navigator instance and onto a thin prototype so Object.getOwnPropertyDescriptor(navigator, 'webdriver') returns undefined, matching Chrome where the property lives on Navigator.prototype. Getter is marked native. sannysoft "WebDriver (New)" now reports "missing (passed)". --- crates/obscura-js/js/bootstrap.js | 52 ++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index fcd1c96..73ef1b0 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2501,6 +2501,28 @@ function _registerIframe(iframeEl) { enumerable: false, }); } +// PluginArray must exist before navigator is built so the plugins getter can use it. +function PluginArray(items) { + for (var _pi = 0; _pi < items.length; _pi++) this[_pi] = items[_pi]; + this.length = items.length; +} +PluginArray.prototype = Object.create(Array.prototype); +PluginArray.prototype.constructor = PluginArray; +PluginArray.prototype.item = function(i) { return this[i] || null; }; +PluginArray.prototype.namedItem = function(name) { + for (var _pi = 0; _pi < this.length; _pi++) { + if (this[_pi].name === name) return this[_pi]; + } + return null; +}; +PluginArray.prototype.refresh = function() {}; +PluginArray.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator]; +Object.defineProperty(PluginArray.prototype, Symbol.toStringTag, {value: 'PluginArray', configurable: true}); +_markNative(PluginArray); +_markNative(PluginArray.prototype.item); +_markNative(PluginArray.prototype.namedItem); +_markNative(PluginArray.prototype.refresh); + 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/', ''); }, @@ -2511,20 +2533,15 @@ globalThis.navigator = { doNotTrack: null, deviceMemory: 8, connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false, onchange: null, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} }, - get webdriver() { return false; }, pdfViewerEnabled: true, get plugins() { - const p = [ - { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, - { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, - { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, - { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, - { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, - ]; - p.item = (i) => p[i] || null; - p.namedItem = (name) => p.find(x => x.name === name) || null; - p.refresh = () => {}; - p[Symbol.iterator] = Array.prototype[Symbol.iterator].bind(p); + const p = new PluginArray([ + { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" }, + { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" }, + { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" }, + { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" }, + { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" }, + ]); return p; }, get mimeTypes() { @@ -2620,6 +2637,17 @@ globalThis.navigator = { }, }; +// Move navigator.webdriver off the instance and onto a thin prototype so that +// Object.getOwnPropertyDescriptor(navigator, 'webdriver') returns undefined, +// matching Chrome where the property lives on Navigator.prototype. +(function() { + var _wdGetter = function() { return false; }; + _markNative(_wdGetter); + var _wdProto = Object.create(Object.getPrototypeOf(globalThis.navigator)); + Object.defineProperty(_wdProto, 'webdriver', {get: _wdGetter, set: undefined, enumerable: true, configurable: true}); + Object.setPrototypeOf(globalThis.navigator, _wdProto); +})(); + globalThis.chrome = { app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } }, runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} }, From a7a7b0f4d862a714e90400c6a10f16ad30458815 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:21:09 +0000 Subject: [PATCH 3/6] stealth: add HTMLMediaElement with canPlayType, Navigator constructor canPlayType was undefined on