Obscura lacked these two standard event constructors on globalThis.
The missing PromiseRejectionEvent caused core-js to misdetect the
environment and override native Promise with a broken polyfill,
breaking Vue application rendering. StorageEvent completes the
localStorage/sessionStorage API surface.
Both are also registered in document.createEvent() for legacy callers.
Fixes#521, fixes#522.
get namespaceURI() derived the namespace from the tag name and only ever
recognised the <svg> root, so every element parsed inside an <svg> subtree
reported the XHTML namespace. get href() gates the SVGAnimatedString
reflection on the SVG namespace, so for parsed content the gate never
opened: href returned a plain string and .animVal/.baseVal were undefined,
making new URL(el.href.animVal) throw. Only createElementNS-built elements
worked, because createElementNS records _ns explicitly.
The tree builder already applies the HTML5 foreign-content rules, so
QualName.ns is correct for parsed SVG; it just was not exposed to JS. Add a
namespace_uri DOM op and read it in get namespaceURI(), keeping _ns as the
override and the old tag-name heuristic as the fallback when no element name
is recorded. Namespaces are immutable, so the result is cached like _lname.
Fixes#484
_IframeDocument's addEventListener, removeEventListener, and dispatchEvent were
no-ops, so listeners registered on an iframe document never ran. Iframe load also
invoked the onload property directly, bypassing addEventListener("load", ...)
listeners.
Register and dispatch events on _IframeDocument, and fire the iframe load event
through the element so both the onload property and addEventListener listeners run.
Fixes#478.
parentNode() inspected only the immediate parent, which caused three
spec deviations:
- With currentNode at root, the `parent !== this.root` guard did not
fire (parent is root's OWN parent), so a node OUTSIDE the walker's
subtree was returned — createTreeWalker(root).parentNode() yielded
<body>. A TreeWalker must never surface a node above its root.
- The same guard excluded root as a *result*, so when currentNode's
accepted ancestor was root itself, parentNode() returned null instead
of root.
- A skipped (or otherwise non-accepted) immediate parent ended the
search rather than continuing up to the first accepted ancestor.
Replaced with the DOM 6.1 algorithm: climb from currentNode, returning
the first ancestor that filters to FILTER_ACCEPT, with the loop's
`node !== this.root` guard keeping the walk inside root while still
letting root be returned. Reuses the three-valued `_filter` from #461.
Last of the TreeWalker mover deviations, after nextNode (#461),
previousNode (#462) and the child/sibling movers (#469).
createNodeIterator returned a TreeWalker with a flag on it. The two are
different interfaces, and the alias got two things wrong.
An iterator's pointer starts *before* its root, so the first nextNode()
returns the root itself; a TreeWalker starts *at* its root and never
returns it. So createNodeIterator(el, SHOW_ELEMENT) silently skipped
exactly the element it was rooted at:
<div id=root><a></a></div> -> ["A"] (Chrome: ["DIV","A"])
It also exposed the wrong surface: no referenceNode,
pointerBeforeReferenceNode or detach (older library code still calls
detach and hit "not a function"), while leaking currentNode, firstChild,
parentNode and the rest of TreeWalker's movers.
Implemented DOM 6.2 "traverse" directly. Keeping the pointer as
(referenceNode, pointerBeforeReferenceNode) rather than a single cursor
is what makes a direction change re-yield the current node instead of
stepping over it, and a failed traversal leaves both untouched so the
iterator stays resumable either way.
The backward step is a new `DomTree::prev_in_subtree`, mirroring
`next_in_subtree`. It can live entirely in the DOM layer because a
NodeIterator prunes nothing -- unlike the TreeWalker's backward walk
(#462), where FILTER_REJECT forces the filter to be consulted at every
step of the descent.
FILTER_REJECT still behaves as FILTER_SKIP here, which the existing test
keeps covering; its expectation gains the root node the alias used to
drop. The TreeWalker's now-dead `_rejectIsSkip` branch is removed.
firstChild/lastChild/nextSibling/previousSibling stepped straight to the
next sibling whenever a node was not accepted, so a FILTER_SKIP node hid
its children instead of exposing them:
<div id=root><section><a></a></section></div>, SECTION -> FILTER_SKIP
w.firstChild() // null (Chrome: <a>)
They also used the boolean `_accept`, so FILTER_REJECT (which must not
descend) and FILTER_SKIP (which must) were indistinguishable — the same
root cause #461 fixed for nextNode().
Replaced all four with the spec's "traverse children" and "traverse
siblings" algorithms, parameterised by direction so forward and backward
share one implementation and cannot drift: `edge` is first/last child and
`step` is next/previous sibling. Both consult the three-valued `_filter`
from #461, so SKIP descends, REJECT prunes, and the climb back out stops
at root or the node the walk started from.
parentNode() is left alone here: its deviations are different (it checks
only the immediate parent, and excludes root, which the spec permits it
to return), so it belongs in its own change.
The parser already put template children in a separate contents document
per the HTML spec (tree_sink.rs), but nothing exposed that node, so
`get content()` fabricated an empty DocumentFragment and the parsed
markup was unreachable:
document.body.innerHTML = '<template id=t><p>a</p></template>';
t.content.childNodes.length // 0 (Chrome: 1)
t.innerHTML // "" (Chrome: '<p>a</p>')
Every <template>-driven UI — custom elements cloning
`template.content`, lit, Vue runtime templates, htmx fragments, Alpine
x-if/x-for — stamped nothing, with no error raised.
Three layers were involved:
- `DomTree::template_contents` returns the contents node, allocating one
on demand so templates built via `createElement` work too. Exposed as
the `template_contents` op; `get content()` wraps it as a
DocumentFragment cached by node id, so `.content` keeps a stable
identity across reads.
- `import_node_from` cloned the element data verbatim, carrying the
*source* tree's contents NodeId into the destination tree, where it
indexed whatever unrelated node occupied that slot. That is why a
template injected via innerHTML resolved `.content` to a bogus node
rather than merely an empty one. The contents are now imported into a
fresh node and the reference remapped, queued onto the existing
worklist so nested templates stay iterative.
- The serializer emitted a template's own (always empty) children, so
contents vanished from outerHTML/innerHTML and `cloneNode(true)` —
which round-trips through outer_html — produced an empty template.
`content_source` resolves a template to its contents document for both
inner_html and outer_html.
With serialization fixed on the Rust side, the `set innerHTML` template
branch that routed to `.content` now has a matching getter for free, so
no template special case is needed on the JS hot path.
window.scrollTo/scrollBy/scroll were empty stubs, scrollX/scrollY and
pageXOffset/pageYOffset were hard-coded 0 data properties, no scroll
event was ever fired at the window, and document.scrollingElement did
not exist.
that "scroll-driven lazy loaders advance instead of throwing". But the
dominant infinite-scroll idiom is window-level:
window.scrollTo(0, document.body.scrollHeight);
if (window.scrollY + innerHeight >= body.scrollHeight) loadMore();
all of which was inert -- the call succeeded, the offset never moved, the
sentinel never tripped -- so automation that scrolls a page to trigger
loading silently got one screenful.
The offset is stored on the scrolling element rather than in separate
window state, so window.scrollY and document.scrollingElement.scrollTop
are two views of one value in both directions, which is what pages
assume when they mix the two. scrollX/scrollY/pageXOffset/pageYOffset
become read-only accessors over it, matching a real Window, and
document.scrollingElement returns documentElement (standards mode; we
never parse in quirks mode).
The scroll event is dispatched at both the document and the window.
Chrome propagates a page scroll to both, but Document.dispatchEvent here
runs only its own listeners and does not propagate, so firing once would
strand half the listeners.
As with #431 there is no layout, so the offset still cannot be clamped to
a real maximum; negative values clamp to 0 exactly as they do for
elements.
previousNode() considered exactly one candidate — the previous sibling's
deepest last child — and returned null as soon as the filter rejected it,
so a backward walk died mid-tree. This is the backward mirror of #432,
which was fixed forward-only in #447.
<div id=root><a><b></b></a><c></c></div>, SHOW_ELEMENT, B -> FILTER_SKIP
forward: ["A","C"] backward: [] (Chrome: ["A"])
Replaced with the DOM 6.1 algorithm verbatim: scan previous siblings,
descend to the deepest last descendant of each, then step up to the
parent, continuing until a node is accepted or root is reached. The
descent stops on FILTER_REJECT, which is what makes rejection prune
subtrees in the backward direction too.
Unlike nextNode this stays in JS rather than adding a DOM traversal op:
the descent has to consult the filter at every step to honour REJECT, so
there is no run of JS/native crossings for a native helper to collapse.
Depends on the three-valued `_filter` from #461.
`_accept` collapsed every non-ACCEPT filter result into a single boolean,
so FILTER_REJECT behaved exactly like FILTER_SKIP: the node was not
returned, but the walk still descended into its subtree. Per DOM 6.1,
FILTER_REJECT rejects a node *and* its descendants.
This was unreachable until #440 exposed the real NodeFilter constants,
which made the canonical prune idiom
if (node.tagName === 'SCRIPT') return NodeFilter.FILTER_REJECT;
start yielding nodes from inside the pruned subtree instead of failing
loudly — sanitizers and text extractors built on TreeWalker silently got
extra content.
Split the filter into a three-valued `_filter` (keeping `_accept` as the
boolean wrapper the sibling/child movers use) and give nextNode a second
traversal step for the reject case. That step lives in the DOM layer as
`DomTree::next_after_subtree`, mirroring how #447 put `next_in_subtree`
there: both share the ancestor climb, and REJECT costs no more JS/native
crossings than SKIP.
NodeIterator shares this walker but has no subtree pruning (DOM 6.2:
FILTER_REJECT behaves as FILTER_SKIP), so `createNodeIterator` marks the
walker `_rejectIsSkip` and keeps the plain step. Covered by its own test
so the shared implementation cannot drift.
scrollTo/scrollBy dispatch a scroll event; direct assignment did not. So the
usual lazy-load idiom
el.addEventListener('scroll', loadMore);
el.scrollTop = el.scrollHeight;
never reached the listener, and scroll-driven feeds stalled after their first
batch with no error to show for it.
Checked against a real Chrome over CDP, same probe on the same page:
Chrome el.scrollTop = 100 -> 1 scroll event
obscura (before) el.scrollTop = 100 -> 0 scroll events
obscura (after) el.scrollTop = 100 -> 1 scroll event
The setters only fire when the offset actually changes, so a loader that
rewrites the same scrollTop each frame does not re-enter. scrollTo/scrollBy
suppress the per-axis events and emit one for the whole movement, the way a
browser reports one scroll per operation rather than one per axis.
The offset stays unclamped on purpose. Without layout, any maximum is a guess,
and a guess derived from the synthetic scroll box pins scrollTop at 0, which
deadlocks the very loaders this unblocks: no scroll, no content, no scroll.
Geometry is left alone here — this is event dispatch only.
Tests: 4 of the 5 new cases fail without this change (the scrollTo coalescing
one passes either way; it guards against this change turning one event into
three). nextest -p obscura-cdp 91/91, obstacle course 33/33.
Move fragment children into the target for appendChild, insertBefore, and replaceChild while preserving their order and emptying the fragment.
Add runtime regression coverage for the DOM attachment portion of #452.
Element.prototype.scrollBy, scrollTo and scroll were undefined; they only
existed as no-ops on the window object. Scroll-driven infinite-scroll code
therefore threw "scrollBy is not a function", and scrollTop/scrollLeft were
no-op stubs that always read back 0.
Track a per-element scroll offset so scrollTop/scrollLeft round-trip, add the
three methods accepting either (x, y) or a ScrollToOptions object, and fire a
scroll event so listeners that drive lazy loading can advance. There is no
layout engine, so the offset is not clamped to a real overflow maximum.
Register the new methods in the native-function list so their toString stays
masked.
Fixes#429.
Co-authored-by: sycatle <[email protected]>
NodeFilter was defined twice: a partial live definition (SHOW_ELEMENT,
SHOW_TEXT, SHOW_ALL) and a complete one behind an `if (typeof NodeFilter
=== 'undefined')` guard that never fired because the partial one already
defined it. So NodeFilter.FILTER_ACCEPT/REJECT/SKIP and most SHOW_* values
were undefined at runtime, and the canonical filter idiom
`acceptNode() { return NodeFilter.FILTER_ACCEPT; }` returned undefined —
making TreeWalker/NodeIterator reject every node.
Make the live definition authoritative with the full standard constant set
and drop the dead duplicate. Adds tests asserting the constant values and
that a filter returning NodeFilter.FILTER_ACCEPT accepts the node.
* fix(js): structuredClone cycles through Error.cause and clones own __proto__ (#419, #420)
The structuredClone polyfill had two correctness/parity gaps in _structuredClone:
- The Error branch recursed into `cause` before recording the clone in `seen`,
so a cycle through the error (e.cause === e) recursed until the stack
overflowed. Chrome clones such graphs and preserves identity. Fix: seen.set
the clone before recursing into cause. (#419)
- The generic-object branch built the clone on the source's prototype and
copied properties by assignment, so an own enumerable `__proto__` data
property (exactly what JSON.parse('{"__proto__":…}') produces) went through
the inherited __proto__ setter and reparented the clone instead of copying
the property. Fix: clone plain objects onto Object.prototype (like Chrome)
and define each property instead of assigning it. (#420)
Adds two parity tests to structured_clone_crypto_parity.rs; full obscura-cdp
suite (73) green.
* perf(js): keep structuredClone fast path for all keys but __proto__
The own-__proto__ fix copied every own property with Object.defineProperty
to avoid triggering the inherited prototype setter. That is ~3x slower
than plain assignment per key, so cloning a large object regressed
(~75ms -> ~228ms for a 1000-key object x200).
Only the __proto__ key needs defineProperty; every other key is safe to
assign. Special-case it so the fast path applies to all other keys.
Measured back to parity with main (85ms vs 85ms); the own-__proto__
correctness test still passes.
---------
Co-authored-by: SGavrl <[email protected]>
HTMLFormElement.requestSubmit(submitter) accepted any element and submitted
silently. Per the HTML spec (and Chrome) it must throw a TypeError when the
submitter is not a submit button, and a NotFoundError DOMException when the
submitter is not owned by the form; both checks run before the submit event.
Adds an _isSubmitButton predicate (a <button> whose type is submit — the
default, including a missing/invalid type — or an <input> of type submit/image)
and validates the argument at the top of requestSubmit. submit() is untouched:
it correctly bypasses the event (#395).
Element.click() now gates on the same predicate rather than its own looser
check, so an internal click can never hand requestSubmit a submitter it would
reject (e.g. <div type="submit">, which is not a submit button and no longer
submits — matching Chrome). That also aligns click() with the CDP click path in
input.rs, which already treats <input type=image> as a submit button. The
input.rs call sites are already guarded by equivalent conditions and pass no
submitter on the Enter-key path, so they are unaffected.
Constraint validation is deliberately out of scope: obscura has no validity
engine (checkValidity is a stub returning true), so wiring it here would be a
no-op. Noted in #424 as separate work.
Full obscura-cdp suite (72) green.
_structuredClone dispatches platform-object hooks as hook(value, seen), but the
CryptoKey hook took only `src` and never touched the memo, and hook branches
return before the generic seen.set. So one CryptoKey referenced twice in a graph
cloned into two distinct objects (and re-registered its key material each time),
where structuredClone preserves reference identity within a graph.
Consult and populate `seen` in the hook. Adds a parity test asserting a key
reached twice clones to one shared object that crypto.subtle still accepts.
Booking.com's anti-bot script pre-defines a non-configurable own `src` on
<img> elements. The Image shim's load emulation then threw "TypeError:
Cannot redefine property: src" out of the constructor, crashing any page
that calls new Image() after that instrumentation runs.
Check the own descriptor first and skip the load emulation when `src` is
already non-configurable: a page that owns `src` is instrumenting loads
itself. The normal path (configurable src, emulated load event) is
unchanged and now covered by a test.
Fixes the crash half of #394.
adoptNode was only stubbed on the createHTMLDocument shim object, never on
Document.prototype, so document.adoptNode(node) threw. importNode already had a
Document.prototype polyfill returning the node, so adoptNode mirrors it: with no
second document to transfer ownership from the node is already ours, return it.
toggleAttribute was missing entirely. Implement spec semantics: no force arg
toggles, force true adds, force false removes, returns the new presence. Lit,
Stencil, and several ad SDKs call it and die on the missing method today.
Fixes#393.
A <link rel=stylesheet href> appended via JS (createElement +
appendChild) was never fetched and fired neither load nor error, so any
Promise.all awaiting the link's onload (lazy CSS loaders, antd/bootstrap
fetchers) hung forever and the page stalled at its pre-load stage.
appendChild already drives this for <script src>; mirror it for links:
resolve the href against <base>/document URL, fetch it, and dispatch
load on success or error on failure. CSS is not applied (no layout
engine yet) but the load-event contract matches Chrome.
Read the stylesheet intent from both the rel attribute and the rel
property, since obscura does not yet reflect the rel IDL attribute back
to the content attribute. Closes#409.
* fix(js): real structuredClone so CryptoKey and buffers survive cloning
structuredClone was JSON.parse(JSON.stringify), which silently drops
ArrayBuffer and TypedArray to {}. A cloned CryptoKey also lost its WeakMap
entry, so crypto.subtle.sign/encrypt/deriveBits threw "Argument is not a
valid CryptoKey" on the clone.
Replace the JSON fallback with a recursive cloner covering ArrayBuffer,
TypedArray, DataView, Map, Set, Date, RegExp, Error, and plain objects,
with cycle and own symbol-keyed property handling. CryptoKey opts in via a
clone hook (keyed by Symbol.toStringTag) that rebuilds the key through
makeKey, so the clone re-enters the WeakMap and stays usable.
Fixes#389.
* fix(js): structuredClone handles DataView and rejects functions and symbols
The TypedArray branch called value.slice(), which DataView lacks, so
cloning any DataView threw TypeError, a new crash in the buffers
category this feature targets. Copy the underlying buffer slice over
the view range and wrap a fresh DataView.
The function/symbol rejection lived after the primitive early-return
and was dead code, so functions and symbols were passed through by
reference instead of throwing DataCloneError (HTML structured clone).
Check first and throw.
Register ArrayBuffer, TypedArray, and DataView in the seen map so
repeated references share one clone, and move the cycle check up.
---------
Co-authored-by: SGavrl <[email protected]>
* fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit()
Per the HTML standard, the submit() method submits without firing a submit
event and cannot be canceled by a page submit listener; only requestSubmit()
and user-initiated submits fire the cancelable event. bootstrap.js dispatched a
cancelable submit event from submit() and aborted navigation when canceled,
which breaks the common pattern where a submit listener preventDefault()s the
native submit and a callback later calls form.submit() to actually send it
(e.g. an invisible-reCAPTCHA data-callback): the listener also canceled the
programmatic submit, so no navigation was ever issued.
- submit(): navigate unconditionally, no submit event
- requestSubmit(): fire the cancelable submit event, navigate only if not canceled
- submit-button clicks route through requestSubmit() (a real click fires the event)
Adds a regression test; full obscura-cdp suite (56 tests) passes.
* fix(cdp): route synthetic submit-button clicks and Enter through requestSubmit
A CDP Input.dispatchMouseEvent on a submit button and an Enter key in a
form field are user-initiated submits, so per the HTML spec they must
fire the cancelable submit event. input.rs called form.submit() (the
method) directly, bypassing any preventDefault() submit listener, which
mirrors the bootstrap.js bug fixed in the parent commit. Use
requestSubmit() with a submit() fallback so the listener can veto.
---------
Co-authored-by: SGavrl <[email protected]>
* Move navigator fingerprint props onto Navigator.prototype
Spoofed navigator properties (userAgent, appVersion, platform, language,
languages, plugins, mimeTypes, hardwareConcurrency, deviceMemory, webdriver,
share, canShare) lived as own-instance accessors/data props on the navigator
literal, which pixelscan's Navigator group flags via hasOwnProperty /
getOwnPropertyDescriptor. Real Chrome keeps them on Navigator.prototype.
Generalize the existing webdriver prototype IIFE to define every spoofed
accessor on a thin prototype one hop above Navigator.prototype, so
navigator.hasOwnProperty('platform') === false while navigator instanceof
Navigator stays true. Getters read the __obscura_* globals lazily (snapshot
vs per-page timing) and are _markNative'd so toString reports [native code].
plugins/mimeTypes are cached singletons.
hardwareConcurrency/deviceMemory now back onto __obscura_hw/__obscura_mem
globals written by __obscura_init, avoiding own data props. Those two globals
are pre-declared in _preHideInternals so the snapshot-time hide list captures
them and they stay invisible to reflection APIs.
* Tighten comments on navigator prototype fingerprint setup
Drop the long narrative blocks in bootstrap.js; keep short notes on why
props sit on the prototype hop, plugin caching, and hw/mem globals.
DOM.setFileInputFiles dispatched plain input/change events, so
event.isTrusted was false. Upload flows and anti-bot code that gate their
change handler on isTrusted ignored them, which silently broke the exact
case the feature targets. The Input domain already marks its synthesized
clicks/keys trusted via __obscura_markTrusted; do the same here.
Fixes#384.
* fix(stealth): hide internal globals from reflection and mark builtins native
Two anti-fingerprinting leaks let pixelscan's bot-check flag obscura as
automated even with --stealth:
- unusualWindowProperties: internal globals (_fp, _wrap, __obscura_*, ...)
were only made non-enumerable, so they stayed hidden from Object.keys and
for-in but not from Object.getOwnPropertyNames / Reflect.ownKeys, which
fingerprinting scripts use. Filter __obscura_hide_list out of the reflection
APIs (getOwnPropertyNames, ownKeys, keys, getOwnPropertyDescriptors) when
they target the global object.
- tamperedFunctions: DOM/Web platform members reimplemented in JS leaked their
source via toString(), e.g. Element.prototype.nodeType returned
"get nodeType() {...}" instead of "[native code]". Per-member _markNative
calls covered methods but missed property accessors and several constructors.
Sweep every builtin constructor reachable from the global object at snapshot
build time and mark its prototype methods, accessors, and the constructor
itself native. Also name the Function.prototype.toString override so it
reports the correct native form for itself.
Verified against pixelscan.net/bot-check: Webdriver -> Clear (tamperedFunctions
and unusualWindowProperties both Clear).
* fix(stealth): build hide list with getOwnPropertyNames
__obscura_hide_list was built with Object.keys, which omits the internal
globals that _preHideInternals already made non-enumerable. Those names were
therefore missing from the list and stayed visible to the reflection-API
filter and to fingerprinting scripts (e.g. __obscura_ua, _registerIframe).
Use getOwnPropertyNames so the list is complete.
Puppeteer's ElementHandle.uploadFile and Playwright's setInputFiles drive
an <input type=file> through CDP DOM.setFileInputFiles, which obscura
rejected as an unknown method, so any upload flow failed (issue #359).
Implement it: resolve the node, read each local file, and hand the bytes
(base64) to the JS layer, which builds real File objects (backed by the
bytes, so page code can read them via FileReader or upload them via
fetch/FormData) and fires input+change like a genuine selection. The file
input exposes a FileList via .files, and .value reads C:\fakepath\<name>
to match Chrome.
Fixes#359.
el.style (CSSStyleDeclaration) and el.dataset (DOMStringMap) were plain
Proxies with only get/set traps, so `'color' in el.style`, `Object.keys
(el.style)`, `'foo' in el.dataset`, and `Object.keys(el.dataset)` all
failed, and Object.keys(el.style) leaked the internal `_props` (issue
#356). cssText also serialized camelCase names without a trailing
semicolon.
Add has / ownKeys / getOwnPropertyDescriptor traps to both proxies so the
`in` operator and enumeration behave like real CSSOM. CSSStyleDeclaration
now stores properties by their dashed name (matching the spec), so
el.style.fontSize and getPropertyValue('font-size') stay in sync, and
cssText serializes dashed names with a trailing semicolon. A known CSS
property list backs `in`/enumeration so feature detection ('object-fit'
in el.style) works for unset properties.
Closes#356.
Typing over CDP into a React controlled input never fired onChange, so
the value never reached component state (issue #324). Two root causes,
both needed:
1. Value tracker. React/Preact/Vue install a value tracker by redefining
`value` on the element instance to record the last value they wrote.
obscura's CDP typing assigned `el.value = ...` directly, which runs
that wrapper and keeps the tracker in sync, so React saw no change on
the input event. Synthetic value/backspace writes now go through a new
__obscura_setFieldValue helper that writes via the element's prototype
setter, bypassing the tracker. The tracker stays stale, so the input
event reads as a real edit.
2. Event handler feature detection. React decides whether to use the
modern `input` event via `("oninput" in document)`. obscura exposed the
GlobalEventHandlers on* attributes only on window, so that check failed
and React fell back to a legacy change-detection path that ignores
input events. The on* attributes are now also present on Document and
Element, matching the HTML spec (GlobalEventHandlers is mixed into
Window, Document, and HTMLElement).
Verified end to end over CDP against the real React 18 UMD build: typing
into controlled inputs updates state, the component re-renders, and a
submit handler reads the captured values.
Closes#324.
new Image() used a bare class shim with no .style, so new Image().style
returned undefined and libraries touching style on a detached image threw
(issue #350). Build the image from document.createElement('img') instead,
which gives a working CSSStyleDeclaration, attribute reflection, and event
dispatch. Assigning .src still emulates a successful decode: it flips
complete and fires load (both the onload property and load listeners) so
lazy loaders and preloaders do not hang.
crypto.subtle only implemented digest. sign returned a fixed 32-byte
buffer, verify always returned true, encrypt and decrypt threw, and
generateKey, importKey and deriveBits handed back placeholder data.
Pages that use WebCrypto (anti-bot probes, PKCE and other auth flows,
client-side crypto) silently got wrong results.
Implement the secret-key half of WebCrypto on RustCrypto, following the
existing op_subtle_digest pattern:
- HMAC sign and verify (SHA-1/256/384/512)
- AES-GCM, AES-CBC and AES-CTR encrypt and decrypt
- PBKDF2 and HKDF deriveBits and deriveKey
- raw and symmetric JWK (oct) importKey and exportKey, generateKey,
wrapKey and unwrapKey
- getRandomValues and randomUUID now draw from the OS CSPRNG instead of
Math.random, so they are uniform across typed-array widths and no
longer a fingerprinting tell
Unsupported algorithms (RSA, ECDSA, ECDH) and key formats (pkcs8, spki)
throw NotSupportedError instead of returning fake data. The crypto runs
in stateless Rust ops; the bootstrap.js shim only marshals bytes and
normalizes algorithm parameters, so the native fast paths are untouched
and there is no cost unless a page calls crypto.
Verified against RFC 4231 (HMAC), the NIST GCM vectors, NIST SP800-38A
(AES-CTR), RFC 6070 (PBKDF2) and RFC 5869 (HKDF), plus roundtrip and
tag/padding tamper-rejection checks.
childNodes returned a plain Array (constructor "Array"), and NodeList
extended Array, so Array.isArray(list) was true and
Object.prototype.toString reported "[object Array]". A real browser
reports "[object NodeList]" and Array.isArray is false, which
feature-detection and fingerprinting scripts check.
NodeList is now its own type with the array-like surface scripts use
(indexed access, length, item, forEach, entries/keys/values, iteration),
and childNodes goes through it. Verified spread, for..of, and Array.from
still work.
Refs #335.
A checkbox or radio input with no value attribute returns "on" in a real
browser; obscura returned the empty string. Page scripts that read
input.value to build form submissions (and browser-environment checks)
saw the wrong default. An explicit value attribute still wins.
Refs #335.
SVG className and href reflected as plain strings, so el.className.animVal
and el.href.animVal were undefined, which made new URL(svgEl.href.animVal)
throw and broke any SVG-aware script. Add an SVGAnimatedString class
(baseVal/animVal both read the live attribute; baseVal writable) and make
the className and href getters return it for SVG-namespace elements. href
falls back to the legacy xlink:href. HTML elements are unchanged.
* Add fetch body serialization support for Blob, ArrayBuffer, and TypedArray
* js: fix Blob fetch body serialization
The Blob branch passed the Blob to _bodyToUint8Array, which did not know
about obscura's Blob (its data lives in _bytes) and fell through to
encoding String(blob), so a Blob body was sent as "[object Blob]".
Teach _bodyToUint8Array about _bytes, and build the binary string with
the loop-based _bytesToBinaryString instead of String.fromCharCode.apply
so large blobs do not overflow the argument limit.
---------
Co-authored-by: SGavrl <[email protected]>
Resolved conflicts:
- page.rs: keep both set_blocked_urls (#315) and stealth client wiring
- ops.rs: keep SHA-512/224 + SHA-512/256 from stealth branch; keep
the unreachable catch-all (vec![]) from #314 instead of silent
SHA-256 fallback; updated comment accordingly
- bootstrap.js: extend the digest algorithm allowlist to include
SHA-512/224 and SHA-512/256 to match the Rust additions
permissions.query returned "granted" for every permission except notifications.
Real Chrome defaults camera, microphone, geolocation, and midi to "prompt", so
answering "granted" for them is a bot tell. Return "prompt" for those (keeping
notifications in sync with Notification.permission) and "granted" for the rest.
fetch() and XHR did String(body), so passing a FormData as the request body
sent the literal "[object Object]" and lost the multipart payload along with
its boundary. Servers expecting multipart/form-data replied 400 "Invalid
boundary for multipart/form-data", which is what blocked booking.com: the AWS
WAF challenge POSTs its proof-of-work solution to /mp_verify as FormData, so
the verify always failed and no aws-waf-token was issued. Serialize FormData to
a multipart/form-data body with a generated boundary and set the matching
Content-Type, the way a real browser does; URLSearchParams bodies get
application/x-www-form-urlencoded. XHR.send routes through fetch() so it is
covered too. With this, obscura --stealth renders the real booking.com landing
page the same as headless Chrome.