Commit Graph
111 Commits
Author SHA1 Message Date
Andrey Mnatsakanov 499a5ef983 fix(js): coerce non-string primitives in before/after/replaceWith
before(), after() and replaceWith() only converted string arguments to Text
nodes; other primitives (numbers, booleans, null) were passed straight to
insert_before with no _nid, corrupting the call. Route them through the shared
_convertNodes helper (the same path append/prepend use), so every non-node
argument becomes document.createTextNode(String(n)).

Fixes #498
2026-07-26 13:30:07 +02:00
ulises2k 3a4e3f7d7d fix(js,dom): keep parsed SVG in the SVG namespace so href reflects an SVGAnimatedString
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
2026-07-25 22:30:38 -03:00
SGavrl 0df158ec9d Merge remote-tracking branch 'origin/main' into pr471-push
# Conflicts:
#	crates/obscura-js/js/bootstrap.js
2026-07-24 15:19:29 +00:00
SGavrl 98f4efae1c Merge pull request #470 from mnaza/fix/window-scroll-468
fix(js): make window-level scrolling actually scroll (#468)
2026-07-24 14:31:00 +00:00
SGavrl 3f820c48c9 Merge pull request #458 from asap-cool/fix/scroll-event-on-assignment
fix(js): fire a scroll event when scrollTop/scrollLeft are assigned
2026-07-24 14:30:47 +00:00
SGavrl 4c8213a62c Merge pull request #479 from j00jun/fix/iframe-event-dispatch
fix(js): make iframe events reach registered listeners
2026-07-24 14:30:31 +00:00
SGavrl a9c25976b2 Merge pull request #476 from mnaza/fix/treewalker-parentnode-475
fix(js): implement DOM parentNode() traversal in TreeWalker (#475)
2026-07-24 14:29:57 +00:00
SGavrl 3c94d69870 Merge pull request #472 from mnaza/fix/node-iterator-467
fix(js): implement a real NodeIterator instead of aliasing TreeWalker (#467)
2026-07-24 14:29:41 +00:00
j00jun 2e3f5d84b1 fix(js): make iframe events reach registered listeners
_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.
2026-07-24 16:34:59 +09:00
Andrey Mnatsakanov 845abb96f7 fix(js): implement DOM parentNode() traversal in TreeWalker (#475)
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).
2026-07-24 08:59:36 +02:00
Andrey Mnatsakanov ab3ca26046 fix(js): implement a real NodeIterator instead of aliasing TreeWalker (#467)
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.
2026-07-24 08:34:33 +02:00
Andrey Mnatsakanov 49d4b91348 fix(js): descend on FILTER_SKIP in TreeWalker child/sibling movers (#469)
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.
2026-07-24 08:33:01 +02:00
Andrey Mnatsakanov ae438e196b fix(dom): bridge <template> contents to JS so .content is not always empty (#463)
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.
2026-07-24 08:31:55 +02:00
Andrey Mnatsakanov f6ca133132 fix(js): make window-level scrolling actually scroll (#468)
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.
2026-07-24 08:30:43 +02:00
Andrey Mnatsakanov a8c0a19f67 fix(js): walk reverse document order in TreeWalker.previousNode() (#462)
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.
2026-07-22 18:59:10 +02:00
Andrey Mnatsakanov 1a5c27a592 fix(js): honour NodeFilter.FILTER_REJECT in TreeWalker (#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.
2026-07-22 18:57:00 +02:00
sycatle 1c7402dfac fix(js): fire a scroll event when scrollTop/scrollLeft are assigned
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.
2026-07-22 14:59:11 +02:00
SGavrl fc9f5248f7 Fix NetworkInformation event listeners 2026-07-22 10:29:51 +00:00
lisa0314 c4f545e68b fix(js): flatten DocumentFragment during node insertion
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.
2026-07-21 18:17:40 +08:00
SGavrl c12915a1a6 fix(js): keep TreeWalker traversal in document order (#432) 2026-07-19 19:40:27 +00:00
Charlie /andsycatle 29e20ae970 fix(js): implement Element scrollBy/scrollTo/scroll and functional scrollTop/scrollLeft (#431)
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]>
2026-07-19 19:12:10 +00:00
Andrey Mnatsakanov c3ae054dde fix(js): give NodeFilter the full standard constant set (#439) (#440)
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.
2026-07-19 19:11:48 +00:00
Andrey MnatsakanovandSGavrl b2e4bb49a7 fix(js): structuredClone cycles through Error.cause and clones own __proto__ (#419, #420) (#421)
* 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]>
2026-07-16 12:56:10 +02:00
Andrey Mnatsakanov ccfa5fb87b fix(js): validate the submitter argument to requestSubmit() (#424) (#426)
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.
2026-07-16 12:42:42 +02:00
Andrey Mnatsakanov 8698afc065 fix(js): CryptoKey clone hook honours the structuredClone seen map (#423) (#425)
_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.
2026-07-16 12:42:07 +02:00
Jim Zhang @ wonderhoi 891d850d19 fix(js): do not redefine img src when a page made it non-configurable (#418)
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.
2026-07-16 12:41:28 +02:00
Jim Zhang @ wonderhoi b460b37e93 fix(js): expose Document.adoptNode and Element.toggleAttribute (#392)
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.
2026-07-14 12:30:40 +00:00
SGavrl c8c78ba229 fix(js): fire load/error for dynamically inserted stylesheet links (#412)
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.
2026-07-14 12:13:25 +00:00
Jim Zhang @ wonderhoiandSGavrl a92166897f fix(js): real structuredClone so CryptoKey and buffers survive cloning (#390)
* 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]>
2026-07-13 23:21:44 +00:00
sickoandSGavrl 7e2cabf411 fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit() (#395)
* 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]>
2026-07-13 23:21:15 +00:00
Vincenzo Territo 87fefef1ec fix(js): move navigator fingerprint props onto Navigator.prototype (#388)
* 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.
2026-07-13 22:46:45 +00:00
Andrey Mnatsakanov c2b79b67fe fix(js): fire trusted input/change from setFileInputFiles (#385)
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.
2026-07-11 16:37:10 +02:00
seffignoz 4c33f6d95e fix(stealth): hide internal globals from reflection and mark builtins native (#377)
* 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.
2026-07-11 16:07:22 +02:00
SGavrl 6788996b0b feat(cdp): implement DOM.setFileInputFiles for file uploads (#369)
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.
2026-07-09 10:51:18 +02:00
SGavrl ad7a7a93d1 fix(js): support in/Object.keys on style and dataset (CSSOM parity) (#357)
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.
2026-07-07 09:26:23 +02:00
SGavrl 08c1f0d721 fix(cdp): fire onChange for React/Vue controlled inputs (#353)
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.
2026-07-03 14:10:03 +02:00
SGavrl a5a8de7c8c fix(js): make new Image() a real HTMLImageElement so .style works
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.
2026-07-03 13:24:20 +02:00
MrBeanDev ed75730e2d feat(js): implement WebCrypto SubtleCrypto secret-key algorithms
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.
2026-07-01 12:11:55 +05:30
SGavrl 25ce541ec8 fix(js): make NodeList a real type and childNodes a NodeList
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.
2026-06-30 15:18:21 +00:00
SGavrl a16e8d4fe3 fix(js): default checkbox and radio value to "on"
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.
2026-06-30 15:18:21 +00:00
SGavrl 50ef1eecbd fix: SVGAnimatedString, :has() selectors, and cookie persistence on serve 2026-06-29 11:00:22 +00:00
SGavrl 117415848e js: implement SVGAnimatedString for SVG className and href (#330)
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.
2026-06-29 10:48:20 +00:00
RezoandSGavrl 260c4c01fa Add fetch body serialization support for Blob, ArrayBuffer, and TypedArray (#329)
* 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]>
2026-06-26 20:43:19 +00:00
SGavrl 8582fcceca Merge stealth-overhaul into main
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
2026-06-23 18:05:55 +00:00
SGavrl db24d7a18c js: prompt for sensitive permissions in navigator.permissions.query
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.
2026-06-23 17:35:37 +00:00
SGavrl 3eb28da5c6 js: serialize FormData bodies to multipart/form-data in fetch
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.
2026-06-23 10:52:57 +00:00
SGavrl 402de261aa js: fix Blob-URL Worker race and update location synchronously on navigation
URL.createObjectURL stored blob content asynchronously (blob.text().then) but
the Worker constructor read __blobStore synchronously, so new Worker(blobURL)
in the same tick raced the store, fell through to fetch(blobURL), and failed
with net::ERR_FAILED. This broke Blob-URL workers including the AWS WAF
challenge proof-of-work worker. Store synchronously from Blob._bytes, which the
obscura Blob materializes in its constructor.

location.reload was a no-op, so challenges that reload after setting a token
cookie never re-fetched. It now navigates like assign/replace.

location.href/assign/replace/reload cleared __virtualUrl immediately, so a
script that set location.href and re-read it saw the old URL until the async
navigation completed; real browsers update location.href synchronously. Preview
the resolved target in __virtualUrl and clear it in __obscura_init once the
navigation completes, so immediate reads match and redirects still win.
2026-06-23 10:28:42 +00:00
SGavrl 4309935abd stealth: keep the Chrome 145 identity consistent across TLS, JS, and WebGL
Stealth mode forced a Linux navigator UA and platform but reported Windows
in userAgentData.getHighEntropyValues and served Windows Direct3D11 WebGL
renderers, a combination no real Linux Chrome ever produces and that
cross-layer fingerprint checks flag. Present one consistent Windows Chrome
145 identity instead: the wreq emulation uses Platform::Windows, and the
navigator surfaces read the UA and platform from shared constants so the
HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, and
userAgentData all agree. userAgentData brands are now derived from the
Chrome major version via Chromium's GREASE algorithm rather than a single
hardcoded token, and a Linux WebGL renderer pool is added so a Linux UA can
never report a Direct3D11 renderer.
2026-06-22 22:19:16 +00:00
Gabriel edde67ddd4 crypto.subtle.digest: reject unsupported algorithms (#314) (#319)
Unknown algorithm names (SHA-512/224, MD5, etc.) were silently falling
back to SHA-256, producing wrong bytes with no error. Now throws
DOMException('NotSupportedError') to match Chrome behavior.

Supported: SHA-1, SHA-256, SHA-384, SHA-512.
2026-06-22 13:02:05 +00:00
Gabriel a8358cc82d add Plugin, MimeType, MimeTypeArray globals (#305) (#318)
These three interfaces were missing from the global scope entirely.
Site bundles that reference Plugin as a constructor threw
"ReferenceError: Plugin is not defined".

Added as plain function declarations (same pattern as PluginArray) so
they survive the V8 snapshot. navigator.plugins now returns real Plugin
instances and navigator.mimeTypes returns a real MimeTypeArray with
MimeType instances, so instanceof checks work correctly.
2026-06-22 13:01:29 +00:00