fix(stealth): synchronize TLS and JS fingerprints to Chrome 145

Upgraded wreq to 6.0.0-rc.29 and wreq-util to 3.0.0-rc.12 to apply the latest Chrome 145 TLS fingerprint (16 cipher suites, proper extension order, ALPS, GREASE).
 Introduced __obscura_stealth global to isolate stealth-specific JS behaviors from the standard execution path.
 Overrode navigator.userAgentData and getHighEntropyValues() in stealth mode to perfectly match the sec-ch-ua network headers.
 Restricted hardwareConcurrency and deviceMemory pools in stealth mode to desktop-realistic values (e.g., minimum 4 cores, 4GB RAM) to eliminate mobile-tier leaks.
 Fixed usedJSHeapSize occasionally exceeding totalJSHeapSize.
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-11 17:53:15 +00:00
commit f58bb45956
6 files changed
+515 -157

No files matched your search

Generated
+100 -74
View File
@@ -8,18 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -44,6 +32,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "1.0.0"
@@ -246,31 +240,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "boring-sys2"
version = "5.0.0-alpha.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669"
dependencies = [
"bindgen 0.72.1",
"cmake",
"fs_extra",
"fslock",
]
[[package]]
name = "boring2"
version = "5.0.0-alpha.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28"
dependencies = [
"bitflags",
"boring-sys2",
"foreign-types",
"libc",
"openssl-macros",
]
[[package]]
name = "brotli"
version = "8.0.3"
@@ -292,6 +261,31 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "btls"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f"
dependencies = [
"bitflags",
"btls-sys",
"foreign-types",
"libc",
"openssl-macros",
]
[[package]]
name = "btls-sys"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b1b8638a2e1c38a5ae4efa90ae57e643baec35a30d03fc5b399b893adc4954b"
dependencies = [
"bindgen 0.72.1",
"cmake",
"fs_extra",
"fslock",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -789,6 +783,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1019,19 +1019,13 @@ dependencies = [
"crc32fast",
]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -1039,6 +1033,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
@@ -1113,7 +1112,6 @@ dependencies = [
"futures-sink",
"http",
"indexmap",
"parking_lot",
"slab",
"smallvec",
"tokio",
@@ -1428,6 +1426,15 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2240,17 +2247,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schnellru"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649"
dependencies = [
"ahash",
"cfg-if",
"hashbrown 0.13.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2746,12 +2742,12 @@ dependencies = [
]
[[package]]
name = "tokio-boring2"
version = "5.0.0-alpha.13"
name = "tokio-btls"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756"
checksum = "2e1fd638ec35427faf3b8f412e0fdd6fae76591d79dba40f38fa667d22bc44dd"
dependencies = [
"boring2",
"btls",
"tokio",
]
@@ -3550,16 +3546,13 @@ dependencies = [
[[package]]
name = "wreq"
version = "6.0.0-rc.28"
version = "6.0.0-rc.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935"
checksum = "3f0eba5f5814a94e5f1a99156f187133464e525b66bdbc69a9627d46530af2e1"
dependencies = [
"ahash",
"boring2",
"brotli",
"btls",
"btls-sys",
"bytes",
"flate2",
"futures-channel",
"futures-util",
"http",
"http-body",
@@ -3568,29 +3561,62 @@ dependencies = [
"httparse",
"ipnet",
"libc",
"lru",
"percent-encoding",
"pin-project-lite",
"schnellru",
"smallvec",
"socket2",
"tokio",
"tokio-boring2",
"tokio-btls",
"tokio-socks",
"tower",
"url",
"want",
"webpki-root-certs",
"zstd",
"wreq-proto",
"wreq-rt",
]
[[package]]
name = "wreq-proto"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a43942f024bb303f1042c9aa3c87fa1d9149f507c65db6e5220a11ccdb207387"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"http2",
"httparse",
"pin-project-lite",
"smallvec",
"tokio",
"tokio-util",
"want",
]
[[package]]
name = "wreq-rt"
version = "0.2.2-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e9bce67a3fa3dd3f1503f066d86661c9caf399a763d3bd184da7afaf886c8b"
dependencies = [
"pin-project-lite",
"tokio",
"wreq-proto",
]
[[package]]
name = "wreq-util"
version = "3.0.0-rc.10"
version = "3.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c6bbe24d28beb9ceb58b514bd6a613c759d3b706f768b9d2950d5d35b543c04"
checksum = "baa5d2ab72139256916ca352a3d05c53d74e1dd360052eb5ba7691033c417c65"
dependencies = [
"brotli",
"flate2",
"typed-builder",
"wreq",
"zstd",
]
[[package]]
+24 -9
View File
@@ -275,19 +275,32 @@ impl Page {
#[cfg(feature = "stealth")]
if self.stealth_client.is_some() {
rt.set_stealth(true);
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);
}
@@ -303,6 +316,8 @@ impl Page {
rt.set_dom(dom);
}
rt.run_page_init();
self.js = Some(rt);
}
+368 -59
View File
@@ -1,5 +1,47 @@
"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',
'__obscura_stealth',
'__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(){};
@@ -2348,9 +2390,51 @@ class DocumentType extends Node {
}
const _cache = new Map();
// Media elements need canPlayType for codec detection fingerprinting.
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
class HTMLMediaElement extends Element {
canPlayType(type) {
if (!type || typeof type !== 'string') return '';
const mime = type.split(';')[0].trim().toLowerCase();
if (mime === 'video/mp4' || mime === 'video/webm' || mime === 'video/ogg') return 'probably';
if (mime === 'video/x-matroska') return 'maybe';
if (mime === 'audio/ogg' || mime === 'audio/webm' || mime === 'audio/wav' ||
mime === 'audio/mpeg') return 'probably';
if (mime === 'audio/mp4' || mime === 'audio/x-m4a' || mime === 'audio/aac') return 'maybe';
return '';
}
load() {}
play() { return Promise.resolve(); }
pause() {}
get paused() { return true; }
get ended() { return false; }
get readyState() { return 0; }
get currentTime() { return 0; }
set currentTime(v) {}
get duration() { return NaN; }
get volume() { return 1; }
set volume(v) {}
get muted() { return false; }
set muted(v) {}
get src() { return this.getAttribute('src') || ''; }
set src(v) { this.setAttribute('src', v); }
}
_markNative(HTMLMediaElement.prototype.canPlayType);
_markNative(HTMLMediaElement.prototype.play);
_markNative(HTMLMediaElement.prototype.load);
_markNative(HTMLMediaElement.prototype.pause);
class HTMLVideoElement extends HTMLMediaElement {}
class HTMLAudioElement extends HTMLMediaElement {}
globalThis.HTMLMediaElement = HTMLMediaElement;
globalThis.HTMLVideoElement = HTMLVideoElement;
globalThis.HTMLAudioElement = HTMLAudioElement;
function _elementClassFor(nid) {
const tag = _domParse("tag_name", nid);
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
if (tag === "AUDIO") return HTMLAudioElement;
if (tag === "VIDEO") return HTMLVideoElement;
return Element;
}
function _wrap(nid) {
@@ -2466,6 +2550,33 @@ function _registerIframe(iframeEl) {
enumerable: false,
});
}
// Navigator constructor so that typeof Navigator !== 'undefined' and
// navigatorPrototype checks don't throw a ReferenceError.
function Navigator() {}
_markNative(Navigator);
// 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/', ''); },
@@ -2475,21 +2586,16 @@ globalThis.navigator = {
vendor: "Google Inc.", product: "Gecko", productSub: "20030107",
doNotTrack: null,
deviceMemory: 8,
connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false, onchange: null, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} },
get webdriver() { return false; },
connection: { effectiveType: "4g", type: "wifi", rtt: 50, downlink: 10, saveData: false, onchange: null, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} },
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() {
@@ -2505,7 +2611,7 @@ globalThis.navigator = {
brands: [
{brand: "Google Chrome", version: "145"},
{brand: "Chromium", version: "145"},
{brand: "Not=A?Brand", version: "24"},
{brand: "Not;A=Brand", version: "24"},
],
mobile: false,
get platform() { return globalThis.__obscura_ua_platform || "Windows"; },
@@ -2513,8 +2619,8 @@ globalThis.navigator = {
return Promise.resolve({
architecture: "x86",
bitness: "64",
brands: [{brand:"Google Chrome",version:"145"},{brand:"Chromium",version:"145"},{brand:"Not=A?Brand",version:"24"}],
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"}],
brands: [{brand:"Google Chrome",version:"145"},{brand:"Chromium",version:"145"},{brand:"Not;A=Brand",version:"24"}],
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: globalThis.__obscura_ua_platform || "Windows",
@@ -2585,9 +2691,21 @@ 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.
// Use Navigator.prototype as the chain base so navigator instanceof Navigator === true.
(function() {
var _wdGetter = function() { return false; };
_markNative(_wdGetter);
var _wdProto = Object.create(Navigator.prototype);
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() {} },
runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { throw new Error("Could not establish connection. Receiving end does not exist."); }, sendMessage() { throw new Error("Could not establish connection. Receiving end does not exist."); } },
csi() {
const t = Date.now();
return { onloadT: t, startE: t - Math.floor(100 + _fpRand(610) * 200), pageT: 0, tran: 5, flashVersion: "" };
@@ -2622,7 +2740,23 @@ globalThis.Notification = class Notification {
globalThis.WebGLRenderingContext = class WebGLRenderingContext {};
globalThis.WebGL2RenderingContext = class WebGL2RenderingContext {};
globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
class Screen {
constructor(w, h) {
this._w = w; this._h = h;
this.colorDepth = 24; this.pixelDepth = 24; this.availTop = 0; this.availLeft = 0;
this.orientation = {type:'landscape-primary',angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}};
}
get width() { return this._w; }
get height() { return this._h; }
get availWidth() { return this._w; }
get availHeight() { return this._h - 40; }
}
['width','height','availWidth','availHeight'].forEach(function(k) {
var d = Object.getOwnPropertyDescriptor(Screen.prototype, k);
if (d && d.get) _markNative(d.get);
});
globalThis.Screen = Screen;
globalThis.screen = new Screen(1920, 1080);
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = 1;
globalThis.innerWidth = 1920; globalThis.innerHeight = 1000;
@@ -4289,7 +4423,14 @@ globalThis.XMLSerializer = class XMLSerializer {
}
};
globalThis.performance = globalThis.performance || {
now: () => Date.now(),
now: (function() {
var _lastMs = -1, _sub = 0;
return function() {
var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
if (ms !== _lastMs) { _lastMs = ms; _sub = 0; } else { _sub += 0.1; }
return ms + _sub;
};
})(),
mark(){}, measure(){},
clearMarks(){}, clearMeasures(){}, clearResourceTimings(){},
getEntries(){return [];}, getEntriesByName(){return [];}, getEntriesByType(){return [];},
@@ -4511,8 +4652,7 @@ globalThis.HTMLLabelElement = Element;
globalThis.HTMLTableElement = Element;
globalThis.HTMLIFrameElement = Element;
globalThis.HTMLCanvasElement = Element;
globalThis.HTMLVideoElement = Element;
globalThis.HTMLAudioElement = Element;
// HTMLVideoElement and HTMLAudioElement are defined above with canPlayType support.
globalThis.HTMLScriptElement = Element;
globalThis.HTMLStyleElement = Element;
globalThis.HTMLLinkElement = Element;
@@ -5067,13 +5207,84 @@ class _IframeWindow {
blur() {}
}
// Encode an RGBA pixel buffer into a valid PNG data URL.
// Uses stored-block DEFLATE (no compression) wrapped in zlib.
// This produces a larger file than a real browser but the hash is unique
// per session (from _fpNoise) and valid, so it does not match the known
// headless stub.
function _encodePNG(w, h, rgba) {
// RGB scanlines: filter byte (0) + 3 bytes per pixel
var rowLen = 1 + w * 3;
var raw = new Uint8Array(h * rowLen);
for (var y = 0; y < h; y++) {
var base = y * rowLen;
raw[base] = 0;
for (var x = 0; x < w; x++) {
var s = (y * w + x) << 2, d = base + 1 + x * 3;
raw[d] = rgba[s]; raw[d+1] = rgba[s+1]; raw[d+2] = rgba[s+2];
}
}
// Adler32 of raw
var s1 = 1, s2 = 0, M = 65521;
for (var i = 0; i < raw.length; i++) { s1 = (s1 + raw[i]) % M; s2 = (s2 + s1) % M; }
var adler = ((s2 << 16) | s1) >>> 0;
// Stored DEFLATE blocks (zlib level 0)
var MAXB = 65535, nb = Math.ceil(raw.length / MAXB) || 1;
var dlen = 2 + nb * 5 + raw.length + 4;
var def = new Uint8Array(dlen), dp = 0;
def[dp++] = 0x78; def[dp++] = 0x01;
for (var bi = 0; bi < nb; bi++) {
var bs = bi * MAXB, be = Math.min(raw.length, bs + MAXB), bl = be - bs;
def[dp++] = bi === nb-1 ? 1 : 0;
def[dp++] = bl&0xff; def[dp++] = (bl>>8)&0xff;
def[dp++] = (~bl)&0xff; def[dp++] = (~bl>>8)&0xff;
def.set(raw.subarray(bs, be), dp); dp += bl;
}
def[dp++]=(adler>>24)&0xff; def[dp++]=(adler>>16)&0xff; def[dp++]=(adler>>8)&0xff; def[dp]=adler&0xff;
// CRC32 (lazy table)
if (!_encodePNG._t) {
var t = new Uint32Array(256);
for (var n = 0; n < 256; n++) { var c = n; for (var k=0;k<8;k++) c=c&1?0xEDB88320^(c>>>1):(c>>>1); t[n]=c; }
_encodePNG._t = t;
}
var T = _encodePNG._t;
function crc32(a, st, ln) { var c=0xFFFFFFFF; for(var i=st,e=st+ln;i<e;i++) c=T[(c^a[i])&0xff]^(c>>>8); return (c^0xFFFFFFFF)>>>0; }
function putChunk(out, off, type, data) {
var dl = data.length;
out[off]=(dl>>24)&0xff; out[off+1]=(dl>>16)&0xff; out[off+2]=(dl>>8)&0xff; out[off+3]=dl&0xff;
out[off+4]=type.charCodeAt(0); out[off+5]=type.charCodeAt(1); out[off+6]=type.charCodeAt(2); out[off+7]=type.charCodeAt(3);
out.set(data, off+8);
var cr = crc32(out, off+4, 4+dl);
out[off+8+dl]=(cr>>24)&0xff; out[off+9+dl]=(cr>>16)&0xff; out[off+10+dl]=(cr>>8)&0xff; out[off+11+dl]=cr&0xff;
return off+12+dl;
}
var ihd = new Uint8Array(13);
ihd[0]=(w>>24)&0xff; ihd[1]=(w>>16)&0xff; ihd[2]=(w>>8)&0xff; ihd[3]=w&0xff;
ihd[4]=(h>>24)&0xff; ihd[5]=(h>>16)&0xff; ihd[6]=(h>>8)&0xff; ihd[7]=h&0xff;
ihd[8]=8; ihd[9]=2; // 8-bit RGB
var png = new Uint8Array(8 + 25 + (12+dlen) + 12);
png.set([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]);
var p = 8;
p = putChunk(png, p, 'IHDR', ihd);
p = putChunk(png, p, 'IDAT', def);
putChunk(png, p, 'IEND', new Uint8Array(0));
// Base64 encode
var C = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
var b64 = 'data:image/png;base64,';
for (var i = 0; i < png.length; i += 3) {
var a=png[i], b=i+1<png.length?png[i+1]:0, c=i+2<png.length?png[i+2]:0;
b64 += C[a>>2] + C[((a&3)<<4)|(b>>4)] + (i+1<png.length?C[((b&15)<<2)|(c>>6)]:'=') + (i+2<png.length?C[c&63]:'=');
}
return b64;
}
globalThis.__ariaQuerySelector = function(root, selector) { return null; };
globalThis.__ariaQuerySelectorAll = async function*(root, selector) { /* yields nothing */ };
class _Canvas2D {
constructor(canvas) {
this.canvas = canvas;
this._w = canvas.width || 300;
this._h = canvas.height || 150;
this._w = parseInt(canvas.getAttribute('width')) || 300;
this._h = parseInt(canvas.getAttribute('height')) || 150;
this._buf = new Uint8ClampedArray(this._w * this._h * 4);
for (let i = 0; i < this._w * this._h; i++) {
this._buf[i*4+0] = 255 + Math.floor(_fpNoise(i % this._w, Math.floor(i / this._w), 0));
@@ -5109,10 +5320,17 @@ class _Canvas2D {
if (x < 0 || x >= this._w || y < 0 || y >= this._h) return;
const idx = (y * this._w + x) * 4;
const alpha = (a / 255) * this.globalAlpha;
this._buf[idx+0] = Math.round(r * alpha + this._buf[idx+0] * (1 - alpha));
this._buf[idx+1] = Math.round(g * alpha + this._buf[idx+1] * (1 - alpha));
this._buf[idx+2] = Math.round(b * alpha + this._buf[idx+2] * (1 - alpha));
this._buf[idx+3] = Math.min(255, Math.round(a * alpha + this._buf[idx+3] * (1 - alpha)));
if (this.globalCompositeOperation === 'multiply') {
this._buf[idx+0] = Math.round((r/255) * (this._buf[idx+0]/255) * 255);
this._buf[idx+1] = Math.round((g/255) * (this._buf[idx+1]/255) * 255);
this._buf[idx+2] = Math.round((b/255) * (this._buf[idx+2]/255) * 255);
this._buf[idx+3] = Math.min(255, this._buf[idx+3] + Math.round(a * alpha));
} else {
this._buf[idx+0] = Math.round(r * alpha + this._buf[idx+0] * (1 - alpha));
this._buf[idx+1] = Math.round(g * alpha + this._buf[idx+1] * (1 - alpha));
this._buf[idx+2] = Math.round(b * alpha + this._buf[idx+2] * (1 - alpha));
this._buf[idx+3] = Math.min(255, Math.round(a * alpha + this._buf[idx+3] * (1 - alpha)));
}
}
fillRect(x, y, w, h) {
const [r,g,b,a] = this._parseColor(this.fillStyle);
@@ -5233,7 +5451,22 @@ class _Canvas2D {
arc(x, y, r, s, e) { if (this._path) this._path.push({t:'A',x,y,r}); }
arcTo() {}
rect(x, y, w, h) { this.fillRect(x, y, w, h); }
fill() {}
fill() {
if (!this._path) return;
const [r,g,b,a] = this._parseColor(this.fillStyle);
for (const seg of this._path) {
if (seg.t === 'A') {
const cx = Math.round(seg.x), cy = Math.round(seg.y), rad = seg.r;
const r2 = rad * rad;
for (let py = Math.max(0, cy - rad); py <= Math.min(this._h - 1, cy + rad); py++) {
for (let px = Math.max(0, cx - rad); px <= Math.min(this._w - 1, cx + rad); px++) {
if ((px-cx)*(px-cx) + (py-cy)*(py-cy) <= r2) this._setPixel(px, py, r, g, b, a);
}
}
}
}
this._path = [];
}
stroke() {}
clip() {}
save() { this._stateStack.push({fillStyle: this.fillStyle, strokeStyle: this.strokeStyle, globalAlpha: this.globalAlpha, font: this.font, lineWidth: this.lineWidth}); }
@@ -5304,18 +5537,7 @@ Element.prototype.getContext = function getContext(type) {
Element.prototype.toDataURL = function(type) {
if (this._ctx && this._ctx._buf) {
const ctx = this._ctx;
const w = ctx._w, h = ctx._h, buf = ctx._buf;
let hash = _fpSeed;
for (let i = 0; i < buf.length; i += 37) {
hash = ((hash << 5) - hash + buf[i]) | 0;
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let b64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg';
for (let i = 0; i < 60; i++) {
hash = ((hash << 5) - hash + i) | 0;
b64 += chars[(hash >>> 0) % 64];
}
return b64 + '==';
return _encodePNG(ctx._w, ctx._h, ctx._buf);
}
return _fp('canvasFingerprint');
};
@@ -5470,7 +5692,11 @@ globalThis.AudioContext = class AudioContext {
createGain() { return {gain:{value:1,setValueAtTime(){}},connect(){},disconnect(){}}; }
createBiquadFilter() { return {type:'lowpass',frequency:{value:350},Q:{value:1},connect(){},disconnect(){}}; }
createBufferSource() { return {buffer:null,connect(){},start(){},stop(){},disconnect(){},loop:false}; }
createBuffer(ch,len,rate) { return {length:len,sampleRate:rate,numberOfChannels:ch,getChannelData(c){return new Float32Array(len);},duration:len/rate}; }
createBuffer(ch,len,rate) {
var chs = [];
for (var _c=0; _c<(ch||1); _c++) chs.push(new Float32Array(len||0));
return {length:len,sampleRate:rate,numberOfChannels:ch,getChannelData:function(c){return chs[c]||chs[0];},duration:len/rate};
}
createScriptProcessor() { return {connect(){},disconnect(){},onaudioprocess:null}; }
decodeAudioData(buf) { return Promise.resolve(this.createBuffer(2,44100,44100)); }
resume() { this.state='running'; return Promise.resolve(); }
@@ -5478,8 +5704,33 @@ globalThis.AudioContext = class AudioContext {
close() { this.state='closed'; return Promise.resolve(); }
};
globalThis.OfflineAudioContext = class OfflineAudioContext extends AudioContext {
constructor(ch,len,rate) { super(); this.length=len||44100; }
startRendering() { return Promise.resolve(this.createBuffer(2,this.length,44100)); }
constructor(ch,len,rate) { super(); this.length=len||44100; this.oncomplete=null; }
startRendering() {
var self = this;
var buf = this.createBuffer(1, self.length, 44100);
var data = buf.getChannelData(0);
// Simulate compressed triangle wave at 10kHz.
// Target: sum(|data[4500..5000]|) matches Chrome Linux (~124.04347527516074).
var target = 124.04347527516074 + (_fpRand(9991) - 0.5) * 0.002;
var freq = 10000, sr = 44100;
for (var i = 0; i < self.length; i++) {
var phase = ((i * freq / sr) % 1 + 1) % 1;
data[i] = phase < 0.5 ? 4*phase - 1 : 3 - 4*phase;
}
var s = 0;
for (var i = 4500; i < 5000; i++) s += Math.abs(data[i]);
var scale = s > 0 ? target / s : 0;
for (var i = 0; i < self.length; i++) data[i] *= scale;
// Fire oncomplete on next microtask so callers can set ctx.oncomplete
// synchronously after calling startRendering() (fpCollect pattern).
var p = Promise.resolve().then(function() {
if (typeof self.oncomplete === 'function') {
try { self.oncomplete({renderedBuffer: buf, target: self, type: 'complete'}); } catch(e) {}
}
return buf;
});
return p;
}
};
globalThis.webkitAudioContext = globalThis.AudioContext;
@@ -5658,6 +5909,32 @@ if (typeof navigator.credentials === 'undefined') {
navigator.credentials = { get(){return Promise.resolve(null);}, create(){return Promise.resolve(null);}, store(){return Promise.resolve();}, preventSilentAccess(){return Promise.resolve();} };
}
navigator.mediaCapabilities = {
decodingInfo(cfg) {
return Promise.resolve({ supported: true, smooth: true, powerEfficient: true, keySystemAccess: null, configuration: cfg });
},
encodingInfo(cfg) {
return Promise.resolve({ supported: true, smooth: true, powerEfficient: true, configuration: cfg });
},
};
navigator.locks = {
request(name, opts, cb) {
if (typeof opts === 'function') { cb = opts; opts = {}; }
if (typeof cb === 'function') return Promise.resolve(cb({ name, mode: (opts && opts.mode) || 'exclusive' }));
return Promise.resolve(null);
},
query() { return Promise.resolve({ held: [], pending: [] }); },
};
navigator.keyboard = {
getLayoutMap() { return Promise.resolve(new Map()); },
lock() { return Promise.resolve(); },
unlock() {},
};
navigator.gpu = { requestAdapter() { return Promise.resolve(null); } };
navigator.wakeLock = { request() { return Promise.reject(new DOMException('Not allowed', 'NotAllowedError')); } };
navigator.share = function(data) { return Promise.reject(new DOMException('Not allowed', 'NotAllowedError')); };
navigator.canShare = function() { return false; };
globalThis.opener = null;
globalThis.Worker = class Worker {
@@ -5746,16 +6023,16 @@ URL.revokeObjectURL = function(url) {
globalThis.scrollTo = function(x, y) {};
globalThis.scrollBy = function(x, y) {};
globalThis.scroll = function(x, y) {};
globalThis.focus = function() {};
globalThis.blur = function() {};
globalThis.print = function() {};
globalThis.alert = function() {};
globalThis.confirm = function() { return true; };
globalThis.prompt = function() { return null; };
globalThis.open = function() { return null; };
globalThis.close = function() {};
globalThis.stop = function() {};
globalThis.postMessage = function() {};
globalThis.focus = function() {}; _markNative(globalThis.focus);
globalThis.blur = function() {}; _markNative(globalThis.blur);
globalThis.print = function() {}; _markNative(globalThis.print);
globalThis.alert = function() {}; _markNative(globalThis.alert);
globalThis.confirm = function() { return true; }; _markNative(globalThis.confirm);
globalThis.prompt = function() { return null; }; _markNative(globalThis.prompt);
globalThis.open = function() { return null; }; _markNative(globalThis.open);
globalThis.close = function() {}; _markNative(globalThis.close);
globalThis.stop = function() {}; _markNative(globalThis.stop);
globalThis.postMessage = function() {}; _markNative(globalThis.postMessage);
globalThis.requestIdleCallback = globalThis.requestIdleCallback || function(cb) { return setTimeout(cb, 0); };
globalThis.cancelIdleCallback = globalThis.cancelIdleCallback || function(id) { clearTimeout(id); };
if (typeof ReadableStream === 'undefined') {
@@ -6004,6 +6281,13 @@ if (typeof EventSource === 'undefined') {
if (typeof WebSocket === 'undefined') {
globalThis.WebSocket = class WebSocket {
constructor(url, protocols) {
// Validate URL scheme per spec — Chrome throws SyntaxError for non-ws/wss URLs
if (typeof url !== 'string' || !/^wss?:\/\//i.test(url)) {
throw new DOMException(
"Failed to construct 'WebSocket': The URL '" + url + "' is invalid.",
'SyntaxError'
);
}
this.url = url;
this.readyState = 0; // CONNECTING
this.bufferedAmount = 0;
@@ -6196,26 +6480,51 @@ globalThis.__obscura_init = function() {
const scr = _fp('screen');
const sw = scr[0], sh = scr[1];
globalThis.screen = { width:sw, height:sh, availWidth:sw, availHeight:sh-40, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
globalThis.screen = new Screen(sw, sh);
globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1;
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80;
globalThis.outerWidth = sw; globalThis.outerHeight = sh;
var hwValues = [2, 4, 6, 8, 12, 16];
var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [2, 4, 6, 8, 12, 16];
globalThis.navigator.hardwareConcurrency = hwValues[Math.floor(_fpRand(400) * hwValues.length)];
var memValues = [0.25, 0.5, 1, 2, 4, 8];
var memValues = globalThis.__obscura_stealth ? [4, 8] : [0.25, 0.5, 1, 2, 4, 8];
globalThis.navigator.deviceMemory = memValues[Math.floor(_fpRand(401) * memValues.length)];
const t0 = Date.now() + Math.floor(_fpRand(641) * 100) - 50;
globalThis.performance.timeOrigin = t0;
globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
var _totalHeap = 15000000 + Math.floor(_fpRand(620) * 85000000);
globalThis.performance.memory = {
jsHeapSizeLimit: 2172649472,
totalJSHeapSize: 15000000 + Math.floor(_fpRand(620) * 85000000),
usedJSHeapSize: 8000000 + Math.floor(_fpRand(621) * 42000000),
totalJSHeapSize: _totalHeap,
usedJSHeapSize: Math.floor(_totalHeap * (0.3 + _fpRand(621) * 0.5)),
};
globalThis.Notification.permission = _fpRand(630) > 0.5 ? "granted" : "default";
globalThis.Notification.permission = "default";
if (globalThis.__obscura_stealth) {
var _stealthBrands = [
{brand: "Not:A-Brand", version: "99"},
{brand: "Google Chrome", version: "145"},
{brand: "Chromium", version: "145"},
];
globalThis.navigator.userAgentData.brands = _stealthBrands;
globalThis.navigator.userAgentData.getHighEntropyValues = function(hints) {
return Promise.resolve({
architecture: "x86", bitness: "64",
brands: _stealthBrands,
fullVersionList: [
{brand:"Not:A-Brand",version:"99.0.0.0"},
{brand:"Google Chrome",version:"145.0.0.0"},
{brand:"Chromium",version:"145.0.0.0"},
],
mobile: false, model: "",
platform: "Linux",
platformVersion: "6.1.0",
uaFullVersion: "145.0.0.0",
});
};
}
// Hide internals (_*, obscura, Obscura). The set of keys is static at
// snapshot-build time, so we precompute it ONCE below (after this
+17 -1
View File
@@ -130,7 +130,7 @@ impl ObscuraJsRuntime {
runtime
.execute_script(
"<obscura:init>",
"globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; globalThis.__obscura_init();".to_string(),
"globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0;".to_string(),
)
.expect("init should not fail");
@@ -220,6 +220,22 @@ impl ObscuraJsRuntime {
);
}
pub fn set_stealth(&mut self, enabled: bool) {
let _ = self.runtime.execute_script(
"<set-stealth>",
format!("globalThis.__obscura_stealth = {};", enabled),
);
}
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
/// have been set. Must be called once per page setup, after all set_* methods.
pub fn run_page_init(&mut self) {
let _ = self.runtime.execute_script(
"<obscura:page-init>",
"globalThis.__obscura_init();".to_string(),
);
}
/// Override the coordinates the navigator.geolocation shim reports. The
/// values are injected as numeric globals the bootstrap reads; when unset it
/// keeps the built-in default. Callers validate the range before calling.
+3 -3
View File
@@ -23,14 +23,14 @@ tempfile = "3"
# Emulation builder reshuffled). A caret requirement let a fresh resolve pull a
# newer, incompatible rc and broke `cargo build --features stealth` (issue #234).
# Pin exact versions so the build always resolves to the API wreq_client.rs targets.
wreq-util = { version = "=3.0.0-rc.10", optional = true }
wreq-util = { version = "=3.0.0-rc.12", optional = true }
# `prefix-symbols` renames BoringSSL exports to avoid clashes with system OpenSSL,
# but the renaming is only correct on Linux and Android (per wreq's README).
# Enabling it on other platforms produces unresolved `build_script_main_*`
# symbols at link time (see issue #39).
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
wreq = { version = "=6.0.0-rc.28", optional = true, features = ["prefix-symbols", "socks"] }
wreq = { version = "=6.0.0-rc.29", optional = true, features = ["prefix-symbols", "socks"] }
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
wreq = { version = "=6.0.0-rc.28", optional = true, features = ["socks"] }
wreq = { version = "=6.0.0-rc.29", optional = true, features = ["socks"] }
+3 -11
View File
@@ -36,21 +36,13 @@ impl StealthHttpClient {
}
pub fn with_proxy(cookie_jar: Arc<CookieJar>, proxy_url: Option<&str>) -> Self {
// Issue #184: `set_default_paths()` reads OpenSSL's compile-time CA
// paths, which only resolve on Linux. On Windows the store ends up
// empty and every TLS handshake fails with CERTIFICATE_VERIFY_FAILED.
// `CertStore::default()` uses wreq's bundled Mozilla roots
// (`webpki-root-certs`), which works the same on every platform.
let cert_store = wreq::tls::CertStore::default();
let emulation_opts = wreq_util::EmulationOption::builder()
.emulation(wreq_util::Emulation::Chrome145)
.emulation_os(wreq_util::EmulationOS::Linux)
let emulation_opts = wreq_util::Emulation::builder()
.profile(wreq_util::Profile::Chrome145)
.platform(wreq_util::Platform::Linux)
.build();
let mut builder = wreq::Client::builder()
.emulation(emulation_opts)
.cert_store(cert_store)
.timeout(Duration::from_secs(30))
.redirect(wreq::redirect::Policy::none());