Commit Graph
693 Commits
Author SHA1 Message Date
Gabriel d08a1deb11 Add Network.getResponseBody support
Add Network.getResponseBody support
2026-06-17 14:53:32 +00:00
SGavrl b27318dc78 test: merge pr-300 2026-06-17 14:33:37 +00:00
yzyf1312 37b33c1109 js: mark Event constructor as native for stealth compatibility 2026-06-14 22:38:04 +08:00
Rohit Madhu 570ca1544c add Network.getResponseBody support 2026-06-13 20:26:26 -04:00
Gabriel af2f89a03e js: implement Element.getAttributeNames()
The method was missing, so calling it threw TypeError: ... is not a function, which broke Vue 3/Nuxt client hydration (Closes #297). Back it with the existing attribute_names op, the same source as the attributes getter, so the order matches attributes and outerHTML.
2026-06-13 16:24:59 +00:00
SGavrl 09fe30ec39 js: implement Element.getAttributeNames()
The method was missing, so calling it threw TypeError: ... is not a
function, which broke Vue 3/Nuxt client hydration (issue #297). Back it with
the existing attribute_names op, the same source as the attributes getter, so
the order matches attributes and outerHTML.
2026-06-13 15:05:29 +00:00
SGavrl 5a0cbd1260 deps: drop unused reqwest cookies feature
cargo install --features stealth failed on Windows with E0119 in cookie 0.18.1: its blanket From<T: Into<Option>> impl fails the coherence check on some toolchains (issue #295). obscura never uses reqwest's cookie store; it manages cookies through its own CookieJar and sets the Cookie/Set-Cookie headers directly. The cookies feature only pulled in the cookie/cookie_store crates (and the whole time subtree).

Drop it. Removes 12 packages including cookie, cookie_store, and time, so the failing crate is no longer compiled in any build. No behavior change: the cookie jar unit tests and the obstacle-course cookies stage still pass.
2026-06-13 08:08:02 +00:00
SGavrl dd49cefa71 deps: drop unused reqwest cookies feature
cargo install --features stealth failed on Windows with E0119 in cookie
0.18.1: its blanket From<T: Into<Option<OffsetDateTime>>> impl fails the
coherence check on some toolchains (issue #295). obscura never uses reqwest's
cookie store; it manages cookies through its own CookieJar and sets the
Cookie/Set-Cookie headers directly. The cookies feature only pulled in the
cookie/cookie_store crates (and the whole time subtree).

Drop it. Removes 12 packages including cookie, cookie_store, and time, so the
failing crate is no longer compiled in any build. No behavior change: the
cookie jar unit tests and the obstacle-course cookies stage still pass.
2026-06-13 07:39:43 +00:00
SGavrl b8696a47ec release: build x86_64-macos on a native Intel runner
The x86_64-apple-darwin release crashed at V8 isolate init with SIGSEGV in ReadOnly snapshot deserialization (issue #290). obscura-js bakes a V8 startup snapshot at build time via a build script, which runs on the host arch. The job built x86_64 on macos-latest (Apple Silicon), so an arm64 snapshot got embedded into the Intel binary and V8 crashed deserializing it. Build on macos-15-intel so the snapshot matches the target.

Drop the aarch64-linux-cross artifact: it has the same defect (x86_64-host snapshot in an arm64 binary) and the native ARM runner already covers it.

Add a post-build smoke test that runs the V8 path on a data: URL so a release can never ship a binary that crashes at isolate init again.

Closes #290
2026-06-12 21:02:17 +00:00
SGavrl c3c0a6a814 release: build x86_64-macos on a native Intel runner
The x86_64-apple-darwin release crashed at V8 isolate init with SIGSEGV in
ReadOnly snapshot deserialization (issue #290). obscura-js bakes a V8 startup
snapshot at build time via a build script, which runs on the host arch. The
job built x86_64 on macos-latest (Apple Silicon), so an arm64 snapshot got
embedded into the Intel binary and V8 crashed deserializing it. Build on
macos-15-intel so the snapshot matches the target.

Drop the aarch64-linux-cross artifact: it has the same defect (x86_64-host
snapshot in an arm64 binary) and the native ARM runner already covers it.

Add a post-build smoke test that runs the V8 path on a data: URL so a release
can never ship a binary that crashes at isolate init again.
2026-06-12 20:56:37 +00:00
SGavrl cd889d5659 Fix/stealth consistency
hasIframeProxy fix contentWindow now returns null when parentNode === null (iframe never inserted into any tree). The previous attempt used isConnected, which broke getBehemothIframe by returning null for iframes inside the phantom iframe's detached document. That caused PHANTOM_DARKNESS to fall back to self (main window), making all DOM measurement ops run on the main document instead of the isolated iframe body, which corrupted the fingerprint element lookup.

Worker auto-execution Worker constructor now resolves relative URLs against location.href, fetches the script, and auto-runs it. The worker scope exposes WorkerGlobalScope so IS_WORKER_SCOPE resolves correctly in creepjs. All three worker types now complete within the 3s timeout instead of silently timing out.

AudioBuffer + AudioContext AudioBuffer class added to globalThis with getChannelData, copyFromChannel, copyToChannel. AudioContext.createBuffer returns a real instance. AudioContext.addEventListener was a no-op; startRendering now dispatches to stored listeners so the complete event fires.

Crypto Crypto class added to globalThis with randomUUID and getRandomValues on its prototype. Fixes ReferenceError in getPlatformEstimate.

NetworkInformation + ContentIndex NetworkInformation class with downlinkMax on the prototype clears noDownlinkMax. ContentIndex stub on globalThis clears noContentIndex. navigator.connection is now a real NetworkInformation instance.

getElementById / querySelector stale index fix id_index only registers nodes at creation time. When replaceChild(fragment, el) swapped in new nodes their IDs hit .or_insert() and were skipped, leaving the index pointing at the detached original. getElementById now verifies the indexed node is in the live document and falls back to a full attribute-selector scan. query_selector_from falls through to the full scan on both index miss and stale entry instead of returning None early.

matchMedia Handles common queries instead of always returning false. prefers-color-scheme: dark returns true (headless Chrome defaults to light mode, so returning true for light is a bot signal). any-pointer: fine, hover: hover, prefers-reduced-motion: no-preference, min/max-width, and (color) return sensible values.

Fingerprint consistency jsHeapSizeLimit raised to 4294705152 (4 GB, matching 64-bit Chrome). getHighEntropyValues platform corrected from "Linux" to "Windows", platformVersion from "6.1.0" to "10.0.0" to match the Windows NT 10.0 UA string. Canvas: real PNG encoder, arc fill, multiply blend mode. Screen class, performance.now, chrome runtime errors fixed.

Build note deviceMemory and hardwareConcurrency use the high-value pools ([4, 8] GB and [4, 6, 8, 12, 16] cores) only when built with --features stealth.
2026-06-11 22:27:44 +00:00
SGavrl 4f722dcfd3 stealth: fix fingerprint consistency, matchMedia, jsHeapSizeLimit
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]).
2026-06-11 22:14:11 +00:00
SGavrl 4e8960e323 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.
2026-06-11 22:01:41 +00:00
SGavrl 93b2fba6a1 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.
2026-06-11 21:51:03 +00:00
SGavrl f58bb45956 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.
2026-06-11 17:53:15 +00:00
SGavrl 56ab54e615 stealth: gate JS behaviours, upgrade wreq, fix fp consistency
wreq upgraded 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).

__obscura_stealth global variable introduced and passed from Rust to JS,
allowing stealth-specific JS behaviors to run only when the stealth flag
is enabled.

Tightened hardwareConcurrency and deviceMemory pools in stealth mode
to desktop-realistic values (e.g. min 4GB RAM, 4 cores), eliminating
mobile-tier values that leak the spoof.

navigator.userAgentData and getHighEntropyValues() are now overridden
in stealth mode to perfectly match the sec-ch-ua header sent by
wreq_util::Profile::Chrome145.

Fixed usedJSHeapSize sometimes exceeding totalJSHeapSize.
2026-06-11 17:22:50 +00:00
SGavrl dc2c585511 stealth: fix audio fingerprint, Screen class, chrome errors, performance.now
OfflineAudioContext.startRendering() now fires oncomplete asynchronously
(microtask) so callers that set ctx.oncomplete after calling startRendering
(fpCollect pattern) get the callback. The rendered buffer contains a
triangle wave scaled to produce the Chrome Linux audio fingerprint sum
(~124.04). createBuffer() now uses persistent Float32Array channels so
written sample values are read back correctly.

screen is now an instance of a Screen class with native getters on the
prototype. Object.getOwnPropertyDescriptor(Object.getPrototypeOf(screen),
"width").get.toString() now returns the expected native-code format instead
of throwing.

chrome.runtime.connect() and sendMessage() now throw the Chrome error
("Could not establish connection. Receiving end does not exist.") instead
of returning silently, matching what fpCollect records for detailChrome.

performance.now() now advances by 0.1ms per call within the same clock
tick, simulating ~100us timer resolution. Previously it aliased Date.now()
which returns the same integer for all calls in one tick, making
redPill2 = Infinity (serialized as null).

WebSocket constructor now throws SyntaxError for non-ws/wss URLs, matching
Chrome's behavior and populating errorsGenerated correctly.
2026-06-11 12:38:53 +00:00
SGavrl b29f0c0eb4 canvas: real PNG encoder, arc fill, multiply blend mode
toDataURL() now encodes the real pixel buffer as a valid PNG using
stored-block DEFLATE, CRC32, and Adler32. The 106-byte stub it replaced
was a static value that any canvas fingerprinter could trivially flag.

fill() rasterizes arc paths from this._path using a distance-check
circle fill, so arc-based canvas tests produce real pixel data.

_setPixel() handles globalCompositeOperation='multiply' so fpCollect's
colored-circle blend test composites correctly.

Canvas dimensions now read from getAttribute('width'/'height') so
explicitly sized canvases (e.g. 400x200) use their declared dimensions
instead of always defaulting to 300x150.
2026-06-11 10:39:52 +00:00
SGavrl a7a7b0f4d8 stealth: add HTMLMediaElement with canPlayType, Navigator constructor
canPlayType was undefined on <audio> and <video> elements. Adds HTMLMediaElement
extending Element with canPlayType returning values that match Chrome 145 on Linux
(probably/maybe/empty per MIME type). HTMLAudioElement and HTMLVideoElement extend
HTMLMediaElement. _elementClassFor routes AUDIO and VIDEO tags to the correct class.

Navigator: adds a global Navigator constructor so typeof Navigator !== 'undefined'.
Links it into the navigator prototype chain (_wdProto = Object.create(Navigator.prototype))
so navigator instanceof Navigator returns true, matching Chrome.

These are two of the signals in server-side fingerprint classifiers (fpCollect et al).
2026-06-11 10:21:09 +00:00
SGavrl a59e756da4 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)".
2026-06-11 10:02:37 +00:00
SGavrl ac75cf52a6 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.
2026-06-11 09:35:58 +00:00
SGavrl 3eada2404c docs: document new flags, stealth identity vars, MCP/SSRF hardening, and DOM-agent CDP methods (#288) 2026-06-10 21:56:43 +00:00
SGavrl 3c92c36862 fix(js): console.log(Error) must not trigger Error.prepareStackTrace
deviceandbrowserinfo.com detects CDP by setting Error.prepareStackTrace as a spy, then calling console.log(new Error()). _consoleFn accessed a.stack, which fires prepareStackTrace if set. Fix: save/clear/restore around the .stack access.
2026-06-10 20:48:45 +00:00
SGavrl 534de77882 Add EventTarget methods to navigator.connection and serviceWorker
NetworkInformation (navigator.connection) and ServiceWorkerContainer (navigator.serviceWorker) are EventTargets in real browsers. React 18 and mobile SPAs call addEventListener on them; the stubs lacked it, so the app bundle crashed with 'addEventListener is not a function' (e.g. goofish.com).
2026-06-10 19:47:23 +00:00
SGavrl ee86dbf996 Scope MCP CORS to the allowlist and cache it at startup 2026-06-10 19:25:44 +00:00
SGavrl afef6ea5bd Normalize SameSite on the CDP cookie path 2026-06-10 19:25:41 +00:00
SGavrl 820643aff4 Add missing #[test] to cookie save/load roundtrip 2026-06-10 19:16:04 +00:00
SGavrl fa4928faa6 Trim redundant comments 2026-06-10 19:15:59 +00:00
charlesmamane26-sketch 6c9d5c55ad Security: cap MCP HTTP request bodies and add optional Origin allowlist (#280)
Reject request bodies above 16 MiB with 413 before allocating, stopping an
unauthenticated Content-Length OOM (vec![0u8; len]). Add an optional,
default-off OBSCURA_MCP_ALLOWED_ORIGINS allowlist; unset keeps the permissive
default so issue #175 hosted dashboards are unaffected.
2026-06-10 19:10:18 +00:00
johnnogueira dd7395a80a fix(cookies): normalize SameSite value to title-case on parse (#270)
SameSite is case-insensitive per RFC 6265bis but servers send it in any
casing (lax, LAX, Lax). Normalize to title-case at parse time so
downstream comparisons work regardless. Unknown values fall back to Lax
per spec.
2026-06-10 19:09:41 +00:00
charlesmamane26-sketchandcharlesmamane26-sketch 6ba3c63596 Security: block SSRF DNS-rebinding and widen the IP deny-set (#279)
Add is_forbidden_ip() (loopback, RFC1918, link-local incl. 169.254.169.254,
broadcast, documentation, 0.0.0.0/::, IPv6 unique-local fc00::/7, IPv4-mapped
IPv6) and SsrfGuardResolver (impl reqwest::dns::Resolve) that resolves and
rejects forbidden addresses at connect time, closing the DNS-rebinding bypass.
Wired into the navigation client and the JS fetch()/XHR path. The
--allow-private-network / OBSCURA_ALLOW_PRIVATE_NETWORK escape hatch is kept.

Co-authored-by: charlesmamane26-sketch <charlesmamane1826@gmail.com>
2026-06-10 19:09:15 +00:00
Ben Younes 38fa85946c fix(js): add insertAdjacentText/insertAdjacentElement polyfills (#285) (#286)
DDoS-Guard's view.js challenge calls Element.prototype.insertAdjacentText
and dies with 'TypeError: t.insertAdjacentText is not a function', blocking
any DDoS-Guard-protected site (e.g. pikabu.ru). insertAdjacentElement was
missing too. insertAdjacentHTML was already implemented, so this just lifts
the same insertion pattern for Text-node and Element insertion semantics.

Both methods mirror the spec: case-insensitive position keywords, four
insertion points (beforebegin/afterbegin/beforeend/afterend), and
insertAdjacentElement returns the inserted element (or null when
beforebegin/afterend has no parent). Registered with _markNative so
Function.prototype.toString keeps reporting [native code] — consistent
with the existing Element.prototype.insertAdjacentHTML stealth registration.
2026-06-10 19:08:46 +00:00
SGavrl 4a00a6ac2c Implement DOM.focus for keyboard input (#283)
CDP clients (browser-use and similar) focus an input via DOM.focus before
typing; Input.dispatchKeyEvent then targets document.activeElement. Without a
DOM.focus handler the focus call errored, keystrokes landed on nothing, and the
field stayed empty. Resolve the node and call its JS focus(), which sets
document.activeElement. Completes the type path for DOM-agent frameworks.
2026-06-09 21:21:51 +00:00
SGavrl f4cbb78f0b Add Target.targetInfoChanged and DOMSnapshot.captureSnapshot for DOM agents (#282)
CDP DOM-agent frameworks (browser-use and similar) could connect but saw a
blank page: navigation never refreshed their cached target url/title, and their
DOM build aborted with "Unknown domain: DOMSnapshot".

- Emit Target.targetInfoChanged after navigation (both CDP Page.navigate and
  JS-initiated) carrying the post-nav url/title. Strict clients refresh a target
  only on this event; it also fixes Puppeteer/Playwright page.url() tracking.
- Implement DOMSnapshot.captureSnapshot, built from the DOM with synthetic
  geometry (no paint engine) and plausible computed styles, keyed by the same
  backendNodeId as DOM.getDocument. Enough for tag/ARIA/accessibility element
  detection; it runs only when a client requests it, so the fetch path is
  unaffected. Coordinate clicking still requires a real layout engine.
2026-06-09 21:02:04 +00:00
zhangyang-crazy-oneandSGavrl 5225f6d985 fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246) (#275)
* fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246)

Add a serialized import queue (__dynScriptQueue + __processDynScriptQueue)
in bootstrap.js so that dynamically inserted <script module> tags execute
one at a time rather than concurrently. This prevents re-entrant RefCell
panics in deno_core's futures_unordered_driver when SPAs (Angular, etc.)
append multiple lazy chunks at once.

Also re-land dynamic import <base href> resolution (previously reverted
as 88af239). The original panic was caused by concurrency, not the URL
resolution logic — now safe because imports are serialized.

Inline module scripts also route through the same queue for consistency.

Fixes #246
Closes #242 (dynamic import base href)

* Fix base-href resolution, queue wedge, and double-fired load event

- Resolve <base href> to an absolute URL before resolving the script src, and
  guard the new URL() call. A path-only base like <base href="/"> (the standard
  Angular form) was passed raw to new URL(src, base), which threw "Invalid URL"
  out of appendChild and broke the SPAs this change targets.
- Wrap the dynamic-script queue drain in try/finally so __dynScriptBusy is always
  cleared. An unexpected throw used to wedge the queue and silently block every
  later dynamic script on the page.
- Fire load/error through dispatchEvent only. dispatchEvent already invokes the
  element's onload property handler, so the extra explicit onload() call
  double-fired it. This also drops the stale enqueue-time callback snapshot and
  reads handlers live off the element.

---------

Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com>
2026-06-09 10:27:50 +02:00
Fabián DelgadoandSGavrl 179d9f3369 fix(js): implement CSS.supports() feature detection (#209)
* fix(js): implement CSS.supports() feature detection

CSS.supports() was a stub that always returned false. Sites that
feature-detect CSS support (e.g. CSS.supports('display','flex'))
treated the engine as lacking flexbox and redirected to their
"unsupported browser" page, so the SPA never rendered.

Implement both call signatures — supports(property, value) and
supports(conditionText) — and return true for well-formed standard
CSS declarations, matching modern browser behaviour. Malformed input
(empty property/value, or a condition string with no colon) returns
false.

* Revert CSS.escape change, keep PR scoped to CSS.supports

The escape stub was switched to String(s), which is unrelated to the
feature-detection fix and still not a spec CSS.escape. Restore the original
stub so this PR only touches CSS.supports.

---------

Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com>
2026-06-09 09:30:41 +02:00
SGavrl f64aca8064 Drop unused context field from obscura::Page (#274)
The wrapper Page cloned a second Arc<BrowserContext> the struct never read.
The inner obscura_browser::Page already holds the context and keeps it alive,
so the field was dead weight. Removes the dead_code warning reported on
aarch64 
Closes #273
2026-06-09 08:55:46 +02:00
SGavrl 82b7a0baa8 Make navigator.geolocation coordinates configurable (#272)
The geolocation shim returned a fixed Frankfurt position. With the timezone now
configurable, a deployment in another region would report a location that does
not match its exit IP or timezone, the mismatch issue #228 describes.

Set OBSCURA_GEOLOCATION="lat,lon" to override the reported coordinates; the
value is range-checked and falls back to the Frankfurt default when unset or
malformed. The existing positional jitter is preserved.
2026-06-08 21:40:38 +00:00
johnnogueiraandSGavrl a23dad7cd9 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <277843151+SGavrl@users.noreply.github.com>
2026-06-08 21:23:50 +00:00
h4ckf0r0day f20626bdbd Update README.md 2026-06-08 21:51:39 +02:00
h4ckf0r0day 5ad8f0bd8f Add MangoProxy sponsor logo 2026-06-08 21:49:35 +02:00
SGavrl 6754c1caac Make AbortSignal a real constructor (prototype, throwIfAborted, events) (#267)
The AbortController/AbortSignal globals were minimal stubs: AbortSignal had no
prototype and no throwIfAborted, and AbortController.abort() only set a flag
without firing listeners. Pages that read AbortSignal.prototype or call
throwIfAborted threw non-fatal TypeErrors.

AbortSignal is now a class with a prototype, aborted/reason, throwIfAborted(),
working addEventListener/onabort firing, and static abort()/timeout()/any().
AbortController.abort(reason) fires the abort event. Bootstrap-only, no perf
impact.
2026-06-08 15:20:37 +02:00
SGavrl 2e82b6c85f Add --quiet to serve (#265)
serve rejected --quiet ("unexpected argument"), so there was no way to
silence the per-page WARN logs (obscura_browser::page script errors) that
flood the console when serving pages with broken scripts. Add the flag to
the Serve command and cover it in is_quiet_command, so it maps to the same
"off" log filter that fetch and scrape already use. Closes #264.
2026-06-08 07:59:04 +00:00
SGavrl 260e138318 Update README.md 2026-06-08 07:41:49 +00:00
SGavrl 3cce892795 Sponsor assets 2026-06-08 07:32:27 +00:00
SGavrl b25cc8fc61 Cap V8 young generation and optimize for size to cut RSS (#263)
The default V8 flags only set --max-old-space-size (a ceiling, not actual
use). The young generation (default 16MB per semi-space) plus V8's
memory-heavy codegen inflate RSS during parse and JS allocation. Add
--max-semi-space-size=4 and --optimize-for-size to the defaults.

Cuts RSS ~10-17% on real pages (ycombinator.com 170MB -> 141MB,
react.dev 94MB -> 83MB, wikipedia 58MB -> 53MB) with no measurable speed
cost: a JIT-sensitive 8M-iteration loop is unchanged (V8 still optimizes
hot paths), and the obstacle course stays 33/33 at the same latency.
Users can still override via --v8-flags.
2026-06-07 12:58:35 +02:00
SGavrl 3fdf0a0be4 perf: bound slow ES modules with a DOM-rendered-aware budget
A slow non-essential ES module could block navigation for ~10s. On real
pages (www.ycombinator.com) obscura spent ~11.5s loading YC's bookface
module, whose top-level eval idle-waits forever, until the phase watchdog
killed it. The page renders fully without it (CPU was ~3s, the rest pure
idle), but wall time was ~2.5x lightpanda.

load_module / load_inline_module now take a per-module budget and bound
both the import-graph fetch and the eval loop with it. page.rs sizes the
budget by whether the body is already rendered: an enhancement module on
a populated page (>50 body descendants) gets a short budget (3s, override
OBSCURA_MODULE_BUDGET_MS); an unmounted SPA shell keeps the full script
budget so the app module still mounts (#205). Fetch concurrency is
unchanged; only the idle eval wait is bounded.

www.ycombinator.com: wall 13.1s -> 6.6s, still 475 links, CPU unchanged.
teragrowth.org (Vite SPA) still renders. Obstacle course 33/33 including
the es-modules / ssr-hydrate / spa stages. Post-drop eval works.
2026-06-07 12:57:31 +02:00
SGavrl fb2841b7ab fix: CJK browser_search panic and multi-tab tab_close crash (#257, #258)
* Fix UTF-8 char boundary panic in browser_search

The search snippet window sliced body[..start] / body[end..] at byte
offsets derived from char counts and needle.len(), which can land inside
a multi-byte (CJK) character and panic, killing the MCP stdio transport.
Snap start/end to char boundaries before slicing and advance past
whitespace by the actual char width. Closes #257.

* Add setLineDash and friends to the canvas 2D mock

Charting libraries (Highcharts, ECharts) call setLineDash/getLineDash/
ellipse every animation frame. The missing setLineDash threw 'is not a
function' from a timer each tick, spamming the console. Adds the
line-dash methods plus ellipse/roundRect/createConicGradient/
getContextAttributes stubs. Addresses the error spam in #258; the
multi-tab isolate-teardown crash is a separate deeper issue.

* Fix multi-tab tab_close V8 isolate abort

rusty_v8 enters each isolate on creation and requires them dropped in
reverse order of creation. The MCP gave each tab its own isolate and
dropped them in arbitrary (user-chosen) order on tab_close, so closing a
non-newest tab aborted the process (Check failed: heap->isolate() ==
Isolate::TryGetCurrent()). Pages with active timers (Highcharts/ECharts)
made it easy to hit.

Enforce the CDP server's single-live-isolate discipline in the MCP: at
most one tab's isolate is ever live. page_mut calls a new activate() that
suspends every other tab (drops its isolate, keeps its DOM) and resumes
the active one. close_tab drops the closed tab's isolate via suspend_js
while it is still the only entered isolate, and tool_close suspends all
tabs before clearing. Confined to obscura-mcp; the engine, CDP server and
obstacle course are untouched, and peak memory drops to one isolate
instead of N. Closes #258.
2026-06-07 11:44:49 +02:00
SGavrl c1e0b0fb5a fix: resolve element .src to an absolute URL (#255) (#260)
HTMLScriptElement/HTMLImageElement .src reflected the literal attribute
instead of the resolved absolute URL. Loaders that derive their base with
new URL(document.currentScript.src).origin threw "Invalid URL" on a
relative src and aborted, so the page bundle never loaded and the DOM
stayed empty (issue #255). getAttribute("src") still returns the literal.
2026-06-07 11:42:56 +02:00
SGavrl 5135e3cb4d perf: DOM read fast paths (constant nodeType, borrowed op reads, base64 table) (#259)
* DOM read fast paths: constant nodeType, fewer ops and allocations

- Element.nodeType is now a constant getter instead of inheriting Node's
  dynamic op-backed getter. nodeType is invariant (1) for element wrappers
  (wrappers are only built for element nodes and node ids are never freed
  and reused), so this drops one JS/Rust op per read on a pervasive path.
  Micro-bench: ~600k nodeType reads go from ~380ms to ~5ms.
- attributes getter reads each attribute once instead of twice.
- dataset Proxy is cached per element instead of reallocated on every
  access, which also makes el.dataset === el.dataset (spec-correct).
- stylesheet <link> scan borrows the node (with_node) instead of deep
  cloning it and compares rel via eq_ignore_ascii_case, dropping a clone
  and a lowercased String alloc per link per navigation.

No regression: obstacle course 33/33, WPT dom/nodes Node- subset identical
to baseline (3551/4163 subtests), targeted DOM probe identical.

* DOM op reads: borrow the node instead of deep-cloning it

The scalar read arms in op_dom (node_type, node_name, tag_name,
get_attribute, attribute_names, parent/first/last/next/prev lookups,
has_child_nodes, pi_target, doctype_name, doctype_public_id) and the
node_root / node_ancestors / node_child_index walks called get_node,
which clones the whole Node (for elements a deep clone of the attribute
Vec and its Strings) just to read one field. Switch them to with_node,
which borrows under the RefCell. element_children and the
document_element / document_doctype child scans stay on get_node on
purpose (they iterate, so the borrow story is left untouched).

Output is identical. Traversal/attribute micro-bench ~25-30% faster with
fewer allocations per op. No regression: obstacle course 33/33, WPT
dom/nodes identical to baseline (7057/10620 across 314 files).

* base64 decode: reverse-lookup table instead of indexOf scan

_base64ToUint8Array decodes every fetch/XHR response body. It ran an
O(64) alphabet.indexOf plus a one-char substring alloc per character.
Build a charCode->6-bit reverse table once at module load and index it by
charCodeAt, folding out-of-range (NaN or code >= 128) to -1 so the
bitmath stays byte-identical to the old indexOf path on valid and
malformed input alike.

~20-25% faster decoding a 2MB body; output byte-identical (differential
test across valid, unpadded, '='-position, >127, non-alphabet and
truncated inputs matches the old code exactly). No regression: obstacle
course 33/33.
2026-06-07 11:39:18 +02:00