From 93b2fba6a13546960a736cda46efc6d7182ca2dd Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 11 Jun 2026 21:51:03 +0000
Subject: [PATCH 1/3] fix: creepjs stealth, AudioBuffer, Crypto, Worker,
hasIframeProxy
Worker auto-execution: Worker constructor resolves relative URLs against
location.href, fetches the script, and auto-runs it. WorkerGlobalScope is
exposed so IS_WORKER_SCOPE resolves correctly. Worker data (navigator,
locale, timezone) now arrives via postMessage within the 3s timeout.
AudioBuffer class added to globalThis with getChannelData, copyFromChannel,
copyToChannel. AudioContext.createBuffer returns a real AudioBuffer. Fixes
ReferenceError that blocked the audio fingerprint section.
Crypto class added to globalThis with randomUUID and getRandomValues on its
prototype. Fixes ReferenceError in getPlatformEstimate.
AudioContext.addEventListener was a no-op; startRendering only fired
oncomplete. Add _listeners map and dispatch to stored listeners.
getElementById and querySelector('#id') returned null for elements inserted
via replaceChild(documentFragment, el). id_index only registers nodes at
creation time; .or_insert skipped IDs on cloned fragments; None caused
query_selector_from to short-circuit the full scan.
Fix querySelector: fall through to full scan on index miss or stale entry.
Fix getElementById: verify node is in live document; fall back to attribute
selector scan if not.
contentWindow getter: return null when parentNode is null (iframe was never
inserted into any tree). Previously returning null for all disconnected
iframes broke getBehemothIframe, which caused PHANTOM_DARKNESS to fall back
to self (main window) instead of the phantom iframe. That made DOC point to
the main document, so rect and font measurements wrote fragment nodes into
the main body, and the subsequent full-DOM scan in getElementById could not
find fingerprint-data through nested fragments in time.
Limiting the null return to parentNode===null (never inserted) means
getBehemothIframe gets a valid nested contentWindow as expected. creepjs
now reports hasIframeProxy: false and 0% stealth detection.
DocumentFragment constructor: pass nid through when provided.
Range.getBoundingClientRect / getClientRects: return commonAncestor rect.
---
crates/obscura-dom/src/selector.rs | 10 +-
crates/obscura-js/js/bootstrap.js | 301 ++++++++++++++++++++++-------
crates/obscura-js/src/ops.rs | 36 +++-
3 files changed, 266 insertions(+), 81 deletions(-)
diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs
index 8bb2847..8ede02f 100644
--- a/crates/obscura-dom/src/selector.rs
+++ b/crates/obscura-dom/src/selector.rs
@@ -565,11 +565,11 @@ impl DomTree {
// querySelector matches strict descendants of root only, so the
// indexed element must have root among its ancestors.
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
- // No element has this id at all: a bare id selector cannot match.
- None => return Ok(None),
- // Indexed (first) element is not under a scoped root; a later
- // duplicate could still be a descendant, so fall through to scan.
- Some(_) => {}
+ // Index miss or stale entry (detached node) — fall through to full scan.
+ // The id_index is best-effort: it only registers nodes at creation time
+ // and does not update on reparent, so it can point to a detached clone
+ // while the live node (with the same id) is elsewhere in the tree.
+ _ => {}
}
}
let selector_list = parse_selector(selector)?;
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 0652b58..5aa8116 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -1622,7 +1622,7 @@ class Element extends Node {
el._iframeDoc = new _IframeDocument('
', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
}
- _registerIframe(el);
+
if (typeof el.onload === 'function') {
try { el.onload(); } catch(e) {}
} else {
@@ -1632,7 +1632,7 @@ class Element extends Node {
}).catch(() => {
el._iframeDoc = new _IframeDocument('', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
- _registerIframe(el);
+
if (typeof el.onload === 'function') try { el.onload(); } catch(e) {}
});
}
@@ -1655,7 +1655,8 @@ class Element extends Node {
get contentWindow() {
if (this.localName !== 'iframe') return undefined;
if (!this._iframeWin) {
- this.contentDocument; // side effect: creates _iframeDoc + _iframeWin
+ if (this.parentNode === null) return null;
+ this.contentDocument;
}
return this._iframeWin;
}
@@ -1796,7 +1797,7 @@ class Element extends Node {
toJSON() { return this; },
};
}
- getClientRects() { return [this.getBoundingClientRect()]; }
+ getClientRects() { return new DOMRectList([this.getBoundingClientRect()]); }
// No layout engine: a stub that always returns true unblocks Playwright's
// actionability polling. With a real layout we'd check display, visibility,
// opacity and rect dimensions per spec.
@@ -2332,6 +2333,9 @@ class Document extends Node {
}
class DocumentFragment extends Node {
+ constructor(nid) {
+ super(nid !== undefined ? nid : +_dom("create_document_fragment"));
+ }
get nodeType() { return 11; }
get nodeName() { return "#document-fragment"; }
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
@@ -2533,17 +2537,30 @@ Object.defineProperty(globalThis.Window, Symbol.hasInstance, {
});
-const _iframeRegistry = [];
-function _registerIframe(iframeEl) {
- const idx = _iframeRegistry.length;
- _iframeRegistry.push(iframeEl);
- globalThis.length = _iframeRegistry.length;
- Object.defineProperty(globalThis, idx, {
- get() { return iframeEl._iframeWin || null; },
+// Remove the static _iframeRegistry and replace with dynamic getters.
+Object.defineProperty(globalThis, 'length', {
+ get() {
+ return document.querySelectorAll('iframe').length;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+// Since we cannot define a Proxy on globalThis easily, we'll define a reasonable number of indexed getters.
+for (let i = 0; i < 50; i++) {
+ Object.defineProperty(globalThis, i, {
+ get() {
+ const iframes = document.querySelectorAll('iframe');
+ if (i < iframes.length) {
+ return iframes[i].contentWindow;
+ }
+ return undefined;
+ },
configurable: true,
- enumerable: false,
+ enumerable: false
});
}
+
// Navigator constructor so that typeof Navigator !== 'undefined' and
// navigatorPrototype checks don't throw a ReferenceError.
function Navigator() {}
@@ -4486,7 +4503,11 @@ Object.defineProperty(Document.prototype, 'fonts', {
},
configurable: true,
});
-globalThis.crypto = globalThis.crypto || { getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } };
+globalThis.Crypto = class Crypto {
+ getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); }
+};
+globalThis.crypto = globalThis.crypto || new globalThis.Crypto();
globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v)));
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
@@ -4920,8 +4941,20 @@ globalThis.Range = class Range {
insertNode(node) { if (node && this._sc && this._sc.insertBefore) { const kids = this._sc.childNodes; this._sc.insertBefore(node, kids[this._so] || null); } }
surroundContents(node) { this.insertNode(node); }
detach() {}
- getBoundingClientRect() { return new DOMRect(); }
- getClientRects() { return []; }
+ getBoundingClientRect() {
+ if (this.collapsed) return new DOMRect();
+ let cac = this.commonAncestorContainer;
+ while (cac && cac.nodeType !== 1 && cac.nodeType !== 9) cac = cac.parentNode;
+ if (cac && cac.getBoundingClientRect) {
+ const r = cac.getBoundingClientRect();
+ return new DOMRect(r.x, r.y, r.width, r.height);
+ }
+ return new DOMRect();
+ }
+ getClientRects() {
+ if (this.collapsed) return new DOMRectList([]);
+ return new DOMRectList([this.getBoundingClientRect()]);
+ }
static get START_TO_START() { return 0; }
static get START_TO_END() { return 1; }
static get END_TO_END() { return 2; }
@@ -5011,6 +5044,7 @@ _markNative(globalThis.Selection);
Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith,
HTMLFormElement.prototype.reset,
Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob,
+ Element.prototype.getBBox,
Node.prototype.appendChild, Node.prototype.removeChild,
Node.prototype.replaceChild, Node.prototype.insertBefore,
Node.prototype.contains, Node.prototype.hasChildNodes, Node.prototype.cloneNode,
@@ -5297,7 +5331,7 @@ class _Canvas2D {
this._stateStack = [];
}
_parseColor(css) {
- if (!css || css === 'none') return [0,0,0,0];
+ if (!css || typeof css !== 'string' || css === 'none') return [0,0,0,0];
if (css.startsWith('#')) {
const hex = css.slice(1);
if (hex.length === 3) return [parseInt(hex[0]+hex[0],16),parseInt(hex[1]+hex[1],16),parseInt(hex[2]+hex[2],16),255];
@@ -5493,6 +5527,13 @@ Element.prototype.getContext = function getContext(type) {
if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {
return {
canvas: this,
+ MAX_VIEWPORT_DIMS: 0x0D33,
+ MAX_TEXTURE_SIZE: 0x0D33,
+ MAX_RENDERBUFFER_SIZE: 0x84E8,
+ MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84EA,
+ MAX_DRAW_BUFFERS_WEBGL: 0x8824,
+ getContextAttributes() { return { alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, powerPreference: "default", premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: true, desynchronized: false }; },
+ uniform2f() {},
getExtension(name) {
if (name === 'WEBGL_debug_renderer_info') return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 };
return null;
@@ -5504,6 +5545,10 @@ Element.prototype.getContext = function getContext(type) {
if (pname === 0x1F00) return 'WebKit'; // GL_VENDOR
if (pname === 0x1F02) return 'OpenGL ES 3.0 (ANGLE)'; // GL_VERSION
if (pname === 0x8B8C) return 'WebGL GLSL ES 3.00 (ANGLE)'; // GL_SHADING_LANGUAGE_VERSION
+ if (pname === undefined) return [0, 0];
+ // Some properties like MAX_VIEWPORT_DIMS return arrays
+ if (pname === 0x0D33) return [8192, 8192];
+ if (pname === 0x8A2A) return [8192, 8192];
return 0;
},
getSupportedExtensions() { return ['WEBGL_debug_renderer_info','EXT_texture_filter_anisotropic','WEBGL_compressed_texture_s3tc','WEBGL_lose_context']; },
@@ -5536,6 +5581,10 @@ Element.prototype.toDataURL = function(type) {
return _fp('canvasFingerprint');
};
Element.prototype.toBlob = function(cb, type, q) { cb(new Blob([''])); };
+Element.prototype.getBBox = function() { return { x: 0, y: 0, width: 0, height: 0 }; };
+Element.prototype.getComputedTextLength = function() { return 0; };
+Element.prototype.getExtentOfChar = function(ch) { return { x: 0, y: 0, width: 0, height: 0 }; };
+Element.prototype.getSubStringLength = function(ch, len) { return 0; };
_markNative(Element.prototype.getContext);
_markNative(Element.prototype.toDataURL);
@@ -5673,24 +5722,37 @@ if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe !== 'func
_markNative(Document.parseHTMLUnsafe);
}
+globalThis.AudioBuffer = class AudioBuffer {
+ constructor(opts) {
+ var o = (typeof opts === 'object' && opts !== null) ? opts : {};
+ this.numberOfChannels = o.numberOfChannels || 1;
+ this.length = o.length || 0;
+ this.sampleRate = o.sampleRate || 44100;
+ this.duration = this.length / (this.sampleRate || 44100);
+ this._chs = [];
+ for (var c = 0; c < this.numberOfChannels; c++) this._chs.push(new Float32Array(this.length));
+ }
+ getChannelData(c) { return this._chs[c] || this._chs[0] || new Float32Array(0); }
+ copyFromChannel(dst, ch, start) { var s=this._chs[ch]||this._chs[0]; start=start||0; for(var i=0;ih!==fn); }
+ _ap(v, min=-3.4028235e38, max=3.4028235e38) { return { value: v, defaultValue: v, minValue: min, maxValue: max, setValueAtTime(){} }; }
+ createOscillator() { return {context:this,type:'sine',frequency:this._ap(440, -22050, 22050),detune:this._ap(0, -153600, 153600),connect(){},start(){},stop(){},disconnect(){},addEventListener(){},removeEventListener(){}}; }
+ createDynamicsCompressor() { return {context:this,threshold:this._ap(_fp('compThreshold'), -100, 0),knee:this._ap(_fp('compKnee'), 0, 40),ratio:this._ap(_fp('compRatio'), 1, 20),attack:this._ap(0.003, 0, 1),release:this._ap(0.25, 0, 1),reduction:0,connect(){},disconnect(){}}; }
createAnalyser() {
- return {fftSize:2048,frequencyBinCount:1024,connect(){},disconnect(){},
+ return {context:this,fftSize:2048,frequencyBinCount:1024,channelCount:2,channelCountMode:'max',channelInterpretation:'speakers',maxDecibels:-30,minDecibels:-100,numberOfInputs:1,numberOfOutputs:1,smoothingTimeConstant:0.8,connect(){},disconnect(){},
getByteFrequencyData(a){for(let i=0;i 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).
+ // Fire oncomplete + 'complete' listeners on next microtask so callers
+ // can register handlers synchronously after startRendering().
var p = Promise.resolve().then(function() {
+ var evt = {renderedBuffer: buf, target: self, type: 'complete'};
if (typeof self.oncomplete === 'function') {
- try { self.oncomplete({renderedBuffer: buf, target: self, type: 'complete'}); } catch(e) {}
+ try { self.oncomplete(evt); } catch(e) {}
+ }
+ var listeners = (self._listeners && self._listeners['complete']) || [];
+ for (var i = 0; i < listeners.length; i++) {
+ try { listeners[i](evt); } catch(e) {}
}
return buf;
});
@@ -5939,18 +6016,75 @@ globalThis.Worker = class Worker {
this._listeners = {};
const worker = this;
- if (typeof url === 'string' && (url.startsWith('blob:') || url.startsWith('http'))) {
- const blobContent = globalThis.__blobStore?.[url];
- if (blobContent) {
- this._code = blobContent;
- } else {
- (async () => {
- try {
- const resp = await fetch(url);
- worker._code = await resp.text();
- } catch(e) { if (worker.onerror) worker.onerror(e); }
- })();
+ let resolvedUrl = url;
+ if (typeof url === 'string') {
+ const blob = globalThis.__blobStore?.[url];
+ if (blob) {
+ worker._code = blob;
+ // Auto-start on next tick so caller can set onmessage first.
+ setTimeout(() => worker._autoRun(), 0);
+ return;
}
+ // Resolve relative URLs against the current page.
+ if (!url.startsWith('http') && !url.startsWith('blob:') && !url.startsWith('data:')) {
+ try { resolvedUrl = new URL(url, globalThis.location?.href || '').href; } catch(e) {}
+ }
+ (async () => {
+ try {
+ const resp = await fetch(resolvedUrl);
+ worker._code = await resp.text();
+ if (!worker._terminated) worker._autoRun();
+ } catch(e) { if (worker.onerror) worker.onerror(e); }
+ })();
+ }
+ }
+ _makeScope() {
+ const worker = this;
+ // WorkerGlobalScope defined + no document property → IS_WORKER_SCOPE = true in creepjs
+ const scope = {
+ WorkerGlobalScope: function WorkerGlobalScope() {},
+ DedicatedWorkerGlobalScope: function DedicatedWorkerGlobalScope() {},
+ postMessage: (msg) => {
+ if (worker._terminated) return;
+ const evt = { data: msg };
+ if (worker.onmessage) worker.onmessage(evt);
+ const ls = worker._listeners['message'] || [];
+ for (const h of ls) h(evt);
+ },
+ addEventListener: (type, fn) => {
+ if (!scope._ev) scope._ev = {};
+ if (!scope._ev[type]) scope._ev[type] = [];
+ scope._ev[type].push(fn);
+ },
+ close: () => { worker._terminated = true; },
+ crypto: globalThis.crypto,
+ Crypto: globalThis.Crypto,
+ TextEncoder: globalThis.TextEncoder,
+ TextDecoder: globalThis.TextDecoder,
+ atob: globalThis.atob,
+ btoa: globalThis.btoa,
+ setTimeout: globalThis.setTimeout,
+ setInterval: globalThis.setInterval,
+ clearTimeout: globalThis.clearTimeout,
+ clearInterval: globalThis.clearInterval,
+ fetch: globalThis.fetch,
+ console: globalThis.console,
+ performance: globalThis.performance,
+ location: globalThis.location,
+ };
+ scope.self = scope;
+ return scope;
+ }
+ _autoRun() {
+ if (this._terminated || !this._code) return;
+ const worker = this;
+ const scope = worker._makeScope();
+ try {
+ const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code);
+ fn(scope, scope.postMessage, scope.addEventListener, scope.close);
+ } catch(e) {
+ console.error('Worker error:', e.message);
+ if (worker.onerror) worker.onerror(e);
}
}
postMessage(data) {
@@ -5958,32 +6092,13 @@ globalThis.Worker = class Worker {
const worker = this;
setTimeout(() => {
if (worker._terminated || !worker._code) return;
+ const scope = worker._makeScope();
try {
- const workerSelf = {
- onmessage: null,
- postMessage: (msg) => {
- const evt = { data: msg };
- if (worker.onmessage) worker.onmessage(evt);
- const handlers = worker._listeners['message'] || [];
- for (const h of handlers) h(evt);
- },
- addEventListener: (type, fn) => { workerSelf['on' + type] = fn; },
- close: () => { worker._terminated = true; },
- crypto: globalThis.crypto,
- TextEncoder: globalThis.TextEncoder,
- TextDecoder: globalThis.TextDecoder,
- atob: globalThis.atob,
- btoa: globalThis.btoa,
- setTimeout: globalThis.setTimeout,
- setInterval: globalThis.setInterval,
- clearTimeout: globalThis.clearTimeout,
- clearInterval: globalThis.clearInterval,
- fetch: globalThis.fetch,
- console: globalThis.console,
- };
const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code);
- fn(workerSelf, workerSelf.postMessage, workerSelf.addEventListener, workerSelf.close);
- if (workerSelf.onmessage) workerSelf.onmessage({ data });
+ fn(scope, scope.postMessage, scope.addEventListener, scope.close);
+ const evs = (scope._ev && scope._ev['message']) || [];
+ if (evs.length) { for (const h of evs) h({ data }); }
+ else if (scope.onmessage) scope.onmessage({ data });
} catch(e) {
console.error('Worker error:', e.message);
if (worker.onerror) worker.onerror(e);
@@ -6128,6 +6243,20 @@ if (typeof DOMRect === 'undefined') {
static fromRect(r={}) { return new DOMRect(r.x,r.y,r.width,r.height); }
};
}
+
+if (typeof DOMRectList === 'undefined') {
+ globalThis.DOMRectList = class DOMRectList {
+ constructor(arr=[]) {
+ this.length = arr.length;
+ for (let i = 0; i < arr.length; i++) this[i] = arr[i];
+ }
+ item(i) { return this[i] || null; }
+ [Symbol.iterator]() {
+ let i = 0, self = this;
+ return { next() { const done = i >= self.length; return { value: done ? undefined : self[i++], done }; } };
+ }
+ };
+}
if (typeof DOMPoint === 'undefined') {
globalThis.DOMPoint = class DOMPoint {
constructor(x=0,y=0,z=0,w=1) { this.x=x;this.y=y;this.z=z;this.w=w; }
@@ -6391,10 +6520,44 @@ if (typeof Range === 'undefined') {
setStart(n,o){this.startContainer=n;this.startOffset=o;} setEnd(n,o){this.endContainer=n;this.endOffset=o;}
collapse(){} selectNode(){} selectNodeContents(){} cloneContents(){return document?.createDocumentFragment();}
deleteContents(){} insertNode(){} getBoundingClientRect(){return new DOMRect();}
- getClientRects(){return [];} cloneRange(){return new Range();} toString(){return '';}
+ getClientRects(){return new DOMRectList([]);} cloneRange(){return new Range();} toString(){return '';}
};
}
+if (typeof FontFace === 'undefined') {
+ globalThis.FontFace = class FontFace {
+ constructor(family, source, descriptors={}) {
+ this.family = family;
+ this.style = descriptors.style || 'normal';
+ this.weight = descriptors.weight || 'normal';
+ this.stretch = descriptors.stretch || 'normal';
+ this.unicodeRange = descriptors.unicodeRange || 'U+0-10FFFF';
+ this.variant = descriptors.variant || 'normal';
+ this.featureSettings = descriptors.featureSettings || 'normal';
+ this.status = 'unloaded';
+ }
+ load() { this.status = 'loaded'; return Promise.resolve(this); }
+ };
+ globalThis.FontFaceSet = class FontFaceSet extends EventTarget {
+ constructor() { super(); this.status = 'loaded'; this.ready = Promise.resolve(this); }
+ add() { return this; }
+ check() { return true; }
+ clear() {}
+ delete() { return false; }
+ load() { return Promise.resolve([]); }
+ forEach() {}
+ has() { return false; }
+ [Symbol.iterator]() { return [][Symbol.iterator](); }
+ };
+ Object.defineProperty(Document.prototype, 'fonts', {
+ get() {
+ if (!this._fonts) this._fonts = new FontFaceSet();
+ return this._fonts;
+ },
+ configurable: true
+ });
+}
+
if (typeof SharedWorker === 'undefined') {
globalThis.SharedWorker = class SharedWorker {
constructor() { this.port = { postMessage(){}, onmessage:null, start(){}, close(){}, addEventListener(){}, removeEventListener(){} }; this.onerror = null; }
@@ -6478,7 +6641,7 @@ globalThis.__obscura_init = function() {
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;
+ globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40;
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)];
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index cd015eb..911e8af 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -137,7 +137,21 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
"null".into()
}
"get_element_by_id" => {
- dom.get_element_by_id(&arg1).map(|id| id.index().to_string()).unwrap_or("-1".into())
+ // Verify the indexed node is in the live document. The id_index is best-effort:
+ // it only registers nodes at creation time and doesn't update on reparent, so
+ // it can point to a detached clone while the live node is elsewhere in the tree.
+ let doc = dom.document();
+ let nid = dom.get_element_by_id(&arg1);
+ let live = nid.filter(|&n| dom.ancestors(n).contains(&doc));
+ match live {
+ Some(n) => n.index().to_string(),
+ None => {
+ // Fall back to full scan for the live document.
+ let sel = format!("[id=\"{}\"]", arg1.replace('\\', "\\\\").replace('"', "\\\""));
+ dom.query_selector(&sel).ok().flatten()
+ .map(|id| id.index().to_string()).unwrap_or("-1".into())
+ }
+ }
}
"query_selector" => {
dom.query_selector(&arg1).ok().flatten().map(|id| id.index().to_string()).unwrap_or("-1".into())
@@ -235,19 +249,22 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
serde_json::to_string(&dom.outer_html(NodeId::new(nid))).unwrap_or("\"\"".into())
}
"append_child" => {
- let parent = arg1.parse::().unwrap_or(0);
- let child = arg2.parse::().unwrap_or(0);
+ // Reject if either nid failed to parse (was "undefined"/empty) — those
+ // default to 0 which is the document root, and silently operating on it
+ // corrupts the tree. Require both args to be valid positive integers.
+ let parent = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
+ let child = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.append_child(NodeId::new(parent), NodeId::new(child));
"true".into()
}
"remove_child" => {
- let child = arg1.parse::().unwrap_or(0);
+ let child = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.remove_child(NodeId::new(child));
"true".into()
}
"insert_before" => {
- let new_node = arg1.parse::().unwrap_or(0);
- let ref_node = arg2.parse::().unwrap_or(0);
+ let new_node = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
+ let ref_node = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.insert_before(NodeId::new(ref_node), NodeId::new(new_node));
"true".into()
}
@@ -261,7 +278,12 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
"true".into()
}
"set_inner_html" => {
- let nid = arg1.parse::().unwrap_or(0);
+ let nid = match arg1.parse::() {
+ Ok(n) if n > 0 => n,
+ // nid=0 is the document root; never allow innerHTML to clear it.
+ // nid parse failure (e.g. "undefined") also falls here.
+ _ => return "false".into(),
+ };
let target = NodeId::new(nid);
let children = dom.children(target);
for child in children {
From 4e8960e323897e042ffd794d7c472e7d3e9286b3 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:01:41 +0000
Subject: [PATCH 2/3] stealth: add NetworkInformation and ContentIndex shims
NetworkInformation class with downlinkMax on its prototype clears
noDownlinkMax from the like-headless flag list. creepjs checks
'downlinkMax' in (window.NetworkInformation?.prototype || {}).
ContentIndex stub on globalThis clears noContentIndex. creepjs checks
'ContentIndex' in window.
navigator.connection is now a real NetworkInformation instance instead
of a plain object literal.
Result: 0% like-headless, 0% headless, 0% stealth on creepjs. 33/33
obstacle course unchanged.
---
crates/obscura-js/js/bootstrap.js | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 5aa8116..ab41ffa 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -2588,6 +2588,23 @@ _markNative(PluginArray.prototype.item);
_markNative(PluginArray.prototype.namedItem);
_markNative(PluginArray.prototype.refresh);
+class NetworkInformation {
+ get downlink() { return 10; }
+ get downlinkMax() { return Infinity; }
+ get effectiveType() { return '4g'; }
+ get rtt() { return 50; }
+ get saveData() { return false; }
+ get type() { return 'wifi'; }
+ get onchange() { return null; }
+ set onchange(v) {}
+ get ontypechange() { return null; }
+ set ontypechange(v) {}
+}
+_markNative(NetworkInformation);
+globalThis.NetworkInformation = NetworkInformation;
+
+globalThis.ContentIndex = class ContentIndex {};
+
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/', ''); },
@@ -2597,7 +2614,7 @@ globalThis.navigator = {
vendor: "Google Inc.", product: "Gecko", productSub: "20030107",
doNotTrack: null,
deviceMemory: 8,
- connection: { effectiveType: "4g", type: "wifi", rtt: 50, downlink: 10, saveData: false, onchange: null, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} },
+ connection: new NetworkInformation(),
pdfViewerEnabled: true,
get plugins() {
const p = new PluginArray([
From 4f722dcfd3ed26e64b02e298322f7e75937946fe Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:14:11 +0000
Subject: [PATCH 3/3] stealth: fix fingerprint consistency, matchMedia,
jsHeapSizeLimit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
jsHeapSizeLimit raised to 4294705152 (~4 GB) to match 64-bit Chrome.
Was 2172649472 (~2 GB).
getHighEntropyValues platform corrected to "Windows" and platformVersion
to "10.0.0" to match the Windows NT 10.0 user agent string. Was "Linux"
/ "6.1.0", which mismatched the UA and could be detected.
matchMedia now handles common queries rather than always returning false:
prefers-color-scheme:dark returns true (avoiding prefersLightColor headless
flag — headless Chrome defaults to light so true is a bot signal),
any-pointer:fine, hover:hover, prefers-reduced-motion:no-preference,
min/max-width, and (color) return sensible values.
Result: 0% like-headless, 0% headless, 0% stealth, 33/33 obstacle course.
Note: build must include --features stealth for deviceMemory and
hardwareConcurrency to use the high-value pools ([4,8] and [4,6,8,12,16]).
---
crates/obscura-js/js/bootstrap.js | 32 ++++++++++++++++++++++++++-----
1 file changed, 27 insertions(+), 5 deletions(-)
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index ab41ffa..226e317 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -3486,7 +3486,29 @@ if (typeof TextDecoder === 'undefined') {
};
}
-globalThis.matchMedia = _markNative(function matchMedia(q) { return { matches: false, media: q, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} }; });
+globalThis.matchMedia = _markNative(function matchMedia(q) {
+ var s = (q || '').toLowerCase().replace(/\s+/g, '');
+ var matches = false;
+ if (s.includes('prefers-color-scheme:light')) matches = false;
+ else if (s.includes('prefers-color-scheme:dark')) matches = true;
+ else if (s.includes('prefers-reduced-motion:no-preference')) matches = true;
+ else if (s.includes('prefers-reduced-motion:reduce')) matches = false;
+ else if (s.includes('any-pointer:fine')) matches = true;
+ else if (s.includes('any-pointer:coarse')) matches = false;
+ else if (s.includes('pointer:fine')) matches = true;
+ else if (s.includes('hover:hover')) matches = true;
+ else if (s.includes('any-hover:hover')) matches = true;
+ else if (s.includes('color)') || s === '(color)') matches = true;
+ else if (s.includes('min-width')) {
+ var m = s.match(/min-width:\s*(\d+)px/);
+ matches = m ? (globalThis.innerWidth || 1440) >= parseInt(m[1]) : false;
+ }
+ else if (s.includes('max-width')) {
+ var m2 = s.match(/max-width:\s*(\d+)px/);
+ matches = m2 ? (globalThis.innerWidth || 1440) <= parseInt(m2[1]) : false;
+ }
+ return { matches: matches, media: q, onchange: null, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} };
+});
globalThis.getComputedStyle = (el) => {
if (!el) el = document.body || {};
const style = el?.style || el?._style || new CSSStyleDeclaration();
@@ -4467,7 +4489,7 @@ globalThis.performance = globalThis.performance || {
timing: { navigationStart: 0, domContentLoadedEventEnd: 0, loadEventEnd: 0 },
navigation: { type: 0, redirectCount: 0 },
memory: {
- jsHeapSizeLimit: 2172649472,
+ jsHeapSizeLimit: 4294705152,
totalJSHeapSize: 19321856,
usedJSHeapSize: 16781520,
},
@@ -6670,7 +6692,7 @@ globalThis.__obscura_init = function() {
globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
var _totalHeap = 15000000 + Math.floor(_fpRand(620) * 85000000);
globalThis.performance.memory = {
- jsHeapSizeLimit: 2172649472,
+ jsHeapSizeLimit: 4294705152,
totalJSHeapSize: _totalHeap,
usedJSHeapSize: Math.floor(_totalHeap * (0.3 + _fpRand(621) * 0.5)),
};
@@ -6693,8 +6715,8 @@ globalThis.__obscura_init = function() {
{brand:"Chromium",version:"145.0.0.0"},
],
mobile: false, model: "",
- platform: "Linux",
- platformVersion: "6.1.0",
+ platform: "Windows",
+ platformVersion: "10.0.0",
uaFullVersion: "145.0.0.0",
});
};