Commit Graph

454 Commits

Author SHA1 Message Date
Andrey Mnatsakanov
c8e482fde9 fix(net): key cookies by (name, path), not name alone (#434) (#438)
CookieJar stored cookies as domain -> name -> entry, so a second
Set-Cookie with the same name but a different path silently overwrote the
first (RFC 6265 §5.3 identifies a cookie by name+domain+path). The
retrieval side already treated path as part of identity — get_cookie_header
path-matches, and delete_cookies_filtered keys on (name, path) — so only
storage lost cookies.

Rekey the inner map by (name, path). A Max-Age=0 / expired Set-Cookie now
deletes exactly the (name, path) it targets instead of every same-name
cookie; delete_cookie(name) still clears all paths (retain by entry name).

Adds tests: same-name/different-path coexistence and no cross-path leak,
same-name/same-path replacement, and path-scoped deletion leaving a
sibling-path cookie intact.
2026-07-19 19:11:33 +00:00
Andrey Mnatsakanov
57949a0efc fix(cdp): backslash-escape key/code in Input.dispatchKeyEvent (#433) (#437)
The keyDown/keyUp paths interpolated `key`/`code` into the generated
KeyboardEvent snippet with single-quote escaping only. Chrome sends
`key: "\"` (U+005C) for the backslash key, which became `key:'\'` — the
backslash escaped the closing quote, ran the string literal on, and made
the whole page.evaluate a syntax error, so the keydown was silently
dropped. Any key/code carrying a backslash broke dispatch.

Escape backslash before single-quote (backslash first, so the quote-escape
backslashes are not themselves doubled), matching the sibling text path in
the same function. Adds a regression test asserting the backslash key
arrives at a keydown listener alongside an ordinary key.
2026-07-19 19:11:17 +00:00
Andrey Mnatsakanov
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 <277843151+SGavrl@users.noreply.github.com>
2026-07-16 12:56:10 +02:00
Jim Zhang @ wonderhoi
f5dc97b131 fix(net): scope passive callbacks to the registering page (#414)
The on_request/on_response Vec lived on the ObscuraHttpClient shared by
every page in the context, so a callback registered on one page fired for
all pages (#408 cross-page bleed) and outlived its page.

Callbacks now live in a per-page CallbackRegistry (Arc shared with the
page's JS runtime state). Page-driven fetch paths (navigation, POST,
scripts, stylesheets, robots.txt, module loads, JS fetch/XHR in both
plain and stealth modes) pass the registry through; the HTTP client
itself is callback-free.

Adds callbacks_do_not_bleed_across_pages: page A's callback stays silent
for page B's requests and stops firing after page A is dropped.
2026-07-16 12:43:13 +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
SGavrl
113b492ed5 Update README.md 2026-07-16 12:07:50 +02:00
SGavrl
99ece0e12d feat(net): make passive request/response callbacks detachable (#416)
on_request/on_response stored callbacks in append-only Vecs with no way
to remove one, so a crawler that installed a response-body capture hook
for one phase could never detach it: it fired for the shared client's
whole lifetime, re-registering across navigations piled up duplicates,
and (because the client is shared per context) it saw sibling pages'
requests too.

Tag each callback with a stable id. on_request/on_response now return
the id and off_request/off_response (remove_on_request/remove_on_response
on the client) detach a specific one. Storage is Vec<(u64, Callback)>;
all fire sites destructure the tuple. Closes #408.
2026-07-14 13:57:45 +00:00
SGavrl
d0b6fc4ffd fix(cdp): emit Network events for script-initiated requests (#415)
Requests made by page JS (fetch/XHR/dynamic script and stylesheet loads)
run in the V8 op layer and never produced a NetworkEvent, so CDP emitted
Network.requestWillBeSent / responseReceived only for the static
navigation subresources. Puppeteer/Playwright page.on('request') and
on('response') therefore missed every XHR/JSON response, which breaks
response capture (and is the root cause of the Aviasales half of #394).

op_fetch_url now records a JsNetworkEvent keyed by the same fetch-{N} id
under which it already stores the response body. The Page drains these
into its network_events before the CDP layer emits, so they flow through
emit_navigation_events as requestWillBeSent / responseReceived /
loadingFinished alongside the document subresources, and
Network.getResponseBody resolves for them via the shared id. Applied to
both navigation paths (server process_navigation and in-process
do_navigate). The queue is capped at 4096 events per page.

Closes #406.
2026-07-14 13:23:55 +00: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
358100aa63 fix(cdp): prune valid_context_ids on executionContextsCleared (#413)
Runtime.executionContextsCleared on navigation told clients every prior
context was gone, but valid_context_ids was insert-only, so a
Runtime.callFunctionOn targeting a pre-navigation context id still ran
instead of being rejected with Chrome's 'Cannot find context with
specified id'. On a long-lived CDP connection the set also grew by one
id per navigation and was never pruned.

Clear valid_context_ids when executionContextsCleared is emitted, then
re-register the default world (id 2) and the isolated worlds as their
executionContextCreated events are emitted. Closes #407.
2026-07-14 12:23:29 +00:00
Andrey Mnatsakanov
5ada3b404b fix(net): default cookie Path to the request directory (RFC 6265 5.1.4) (#403)
* fix(net): default cookie Path to the request directory (RFC 6265 5.1.4)

When a Set-Cookie carried no Path attribute, the jar stored the full request
path as the cookie's path. RFC 6265 5.1.4 default-path is the request URI's
directory — the path up to but not including the right-most '/'. Storing the
full path scoped a session cookie to the exact URL that set it: a cookie set on
GET /app/login was stored with path=/app/login and then, because path-match
(5.1.4, fixed earlier) requires a '/' boundary, was not sent to /app/dashboard.
The user appeared logged out on the very next navigation.

Add default_cookie_path() implementing the RFC algorithm and use it as the
initial path in both set_cookie (HTTP) and set_cookie_from_js (document.cookie).
An explicit Path attribute still overrides it. Tests cover the algorithm and the
end-to-end 'cookie set on /app/login is sent to /app/dashboard' path for both
the HTTP and JS entry points; both failed before this change.

* fix(cdp): apply RFC 6265 default cookie Path to CDP-injected cookies

Network.setCookie/setCookies (the Puppeteer page.setCookie and
Playwright addCookies surface) derived the cookie Path from the full
request URL when the caller omitted path, the same bug the parent
commit fixed for Set-Cookie responses. Reuse default_cookie_path (now
pub) so a cookie set under /v1/things scopes to /v1 and is resent on
sibling paths. Fixes the CDP half of #401.

Correct the test that locked in the old full-path behavior and add a
case confirming an explicit Path still wins.

---------

Co-authored-by: SGavrl <277843151+SGavrl@users.noreply.github.com>
2026-07-14 12:13:49 +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 @ wonderhoi
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 <277843151+SGavrl@users.noreply.github.com>
2026-07-13 23:21:44 +00:00
sicko
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 <277843151+SGavrl@users.noreply.github.com>
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
367601b1ca fix(dom): neutralize all comment terminators when serializing, not just "-->" (#402)
The previous fix escaped only "-->" in comment data, but the HTML tokenizer
ends a comment on four sequences: "-->", "--!>", a leading ">", and a leading
"->". document.createComment(...) accepts arbitrary strings, so a scripted
comment like ">​<img src=x onerror=alert(1)>" still serialized to
"<!--><img ...>-->": the leading ">" abrupt-closes the empty comment and the
trailing <img> parses as live markup (mXSS). The "--!>" and "->" forms escape
the same way.

Every terminator form requires a ">", and entities are not decoded inside
comments, so escape every ">" to "&gt;": this neutralizes all four at once,
keeps the payload inside a single comment, and supersedes the "-->"-only guard.

Regression test builds a scripted comment for each terminator form, serializes,
re-parses, and asserts no <img> escapes; the leading-">", leading-"->", and
"--!>" cases failed before this change.
2026-07-13 22:46:16 +00:00
Andrey Mnatsakanov
c915a11f59 fix(cdp): truncate client-controlled log previews on char boundaries (#397)
server.rs logged previews of attacker-controlled strings with raw byte
slicing: `&text[..text.len().min(200)]` for a malformed CDP frame (inside a
`warn!`, which is active at the worker's default log level) and the same for
an intercepted event JSON. `&s[..n]` panics when byte `n` falls inside a
multi-byte UTF-8 character, so a single WebSocket frame that is invalid JSON,
>=201 bytes, with a multi-byte char straddling byte 200 panics the CDP
processor task — an unauthenticated one-frame crash. page.rs had the same
pattern (behind --verbose debug logging) for evaluated JS expressions.

Add truncate_on_char_boundary (str::floor_char_boundary is still unstable) and
route all four sites through it. Regression test constructs the exact 'a'*199 +
'€' input, asserts the old raw slice panics (via catch_unwind) and the helper
truncates on the boundary instead.
2026-07-13 22:45:57 +00:00
Andrey Mnatsakanov
d8e9fc2196 fix(cdp): bound DOM.getDocument depth so deep DOMs cannot crash the worker (#396)
DOM.getDocument{depth:-1} (the standard full-tree call from Puppeteer/
Playwright/browser-use) arrived as u32::MAX and drove serialize_node to
recurse once per DOM nesting level. A page can nest nodes tens of thousands
deep (trivially scriptable; html5ever caps no generic nesting), so the
recursion overflowed the stack — a SIGABRT that catch_unwind cannot recover,
crashing the whole worker (AGENTS.md: "one page must never crash a worker").

Deriving the builder alone is not enough: the response is inherently nested
JSON, and serde_json's own serialization and the Value's Drop recurse over
that nesting too, overflowing the ~2 MiB tokio worker stack. So this both
rewrites serialize_node as an explicit heap worklist and clamps the effective
depth to MAX_SERIALIZE_DEPTH (256), mirroring DOMSnapshot's MAX_NODES guard.
Real DOMs are far shallower; clients wanting a deeper subtree re-request it
with DOM.requestChildNodes / describeNode.

Adds a regression test that builds a 50k-deep tree and serializes with
depth:-1; it SIGABRTs before this change and passes after (run on nextest's
2 MiB test thread, mirroring production).
2026-07-13 22:42:30 +00:00
Flo
82d20bcb7c fix(cdp): allow large WebSocket response frames (<64Mb) (#405) 2026-07-13 22:41:20 +00:00
Andrey Mnatsakanov
50e66320b0 fix(cdp): bound IO stream buffers so downloads cannot leak (#387)
* fix(js): fire trusted input/change from setFileInputFiles

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(cdp): bound IO stream buffers so downloads cannot leak

Fetch.takeResponseBodyAsStream moved a response body out of the page's
LRU-bounded cache into CdpContext.io_streams, a server-lifetime map freed only
by an explicit IO.close. A client that opens a stream and disconnects, errors
mid-download, or never closes it leaked the full buffer forever; no teardown
path (WS close, Browser.close, Target.closeTarget) touched io_streams. That
reintroduced the unbounded memory growth streaming was meant to avoid (#360).

Replace the raw map + counter with an IoStreamStore that caps the number of
open streams and their total bytes (OBSCURA_IO_STREAM_MAX_ENTRIES / _BYTES),
evicting the oldest first and always keeping the just-opened stream. Reading an
evicted handle fails cleanly, which is the right trade against an OOM. Read
chunking is unchanged.

Fixes #386.
2026-07-11 16:42:57 +02:00
Andrey Mnatsakanov
d4fd2884a2 fix(net): match cookie Path on a / boundary (RFC 6265) (#383)
Cookie path filtering used `request_path.starts_with(cookie_path)`, so a
Path=/admin cookie was sent to sibling paths that merely share the string
prefix, such as /administrator or /admin-backup. Per RFC 6265 5.1.4 a prefix
match also requires the boundary to fall on a '/'.

Add a path_matches helper (identical path, or a prefix ending in '/', or a
prefix followed by '/') and use it in get_cookie_header and
get_js_visible_cookies.

Fixes #382.
2026-07-11 16:37:28 +02: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
1ca6047351 feat(cdp): stream large response bodies via takeResponseBodyAsStream + IO (#381)
Network.getResponseBody returns the whole body in one base64 blob and the
cache drops anything over 2 MiB, so large downloads (images, PDFs,
archives) either failed or forced the long-running server to hold the
full body in memory (issue #360).

Add Fetch.takeResponseBodyAsStream + IO.read + IO.close. The body is moved
out of the page cache into a stream handle and delivered in bounded chunks
via IO.read, then freed on IO.close, so it is held once and released after
the download. Binary bodies are byte-exact (stored base64). To stream a
large body, raise OBSCURA_NETWORK_BODY_BUFFER_BYTES so it is cached.

This is entirely opt-in: nothing runs unless a client calls the new
methods, so the default fetch/render/scrape path is unchanged (verified:
obstacle course 33/33, obscura-vs-Chrome head-to-head within noise of the
pre-change baseline).

Fixes #360.
2026-07-10 20:38:28 +00:00
SGavrl
d3a8b9a482 fix(js): return module eval on completion, not loop idle (Vite mount) (#380)
load_module and load_inline_module drove run_event_loop to full idle
before reading the module's mod_evaluate result. A page timer keeps the
loop busy forever (Vite's HMR / React-Refresh client installs a
setInterval), so the preamble inline module burned the entire budget
waiting for an idle that never came, and the later module that mounts the
app was skipped, leaving #root empty while the production preview rendered
fine (issue #374).

Drive the event loop and the mod_evaluate result together and return as
soon as the module finishes evaluating, so a busy background timer no
longer starves the remaining modules. Post-eval async work is still
drained by the normal post-load settle.

Fixes #374.
2026-07-10 20:36:08 +00:00
Andrey Mnatsakanov
68234970b1 fix(cdp): apply Fetch.continueRequest overrides (#373)
handle_fetch_resolution built InterceptResolution::Continue with url, method,
headers, and body hardcoded to None, so Fetch.continueRequest silently sent the
request unmodified. Playwright route.continue({ headers, url, postData }) and
Puppeteer request.continue reported success while their overrides were dropped.

op_fetch_url already applies all four (and re-validates a rewritten URL through
the SSRF gate); only the CDP layer was discarding them. Read url, method,
headers, and postData from the continueRequest params and pass them through.

Fixes #365.
2026-07-10 20:35:36 +00:00
Andrey Mnatsakanov
daa0d62fbd fix(cdp): route navigation on exact method, not substring (#372)
The cdp_processor decided whether to take the spawn-and-defer navigation path
with `text.contains("Page.navigate")`, which also matches
`Page.navigateToHistoryEntry`. goBack / goForward (both Puppeteer and
Playwright drive Page.navigateToHistoryEntry) were misrouted into the raw
navigate path, which reads an absent `url` param and fails with
"Invalid URL: relative URL without a base", so history navigation never
reached its real handler. Any frame merely embedding the literal
"Page.navigate" (e.g. a Runtime.evaluate expression) was misrouted the same
way.

Match on the parsed CDP method instead, so only an exact Page.navigate takes
the navigation path.

Fixes #363.
2026-07-10 20:23:53 +00:00
SGavrl
4bc69c5209 Update README.md 2026-07-10 19:44:18 +00:00
SGavrl
fa9ba7f13d fix(browser): raise script-execution budget so heavy SPAs boot (#371)
The script-execution phase had a 10s soft deadline. On a heavy SPA the
remaining scripts were skipped before the app finished booting, so it
never fired its XHR/fetch calls and page.on('response') captured zero API
responses (issue #361). The knob to extend it, OBSCURA_SCRIPT_DEADLINE_MS,
existed but was undocumented, so it was undiscoverable.

Raise the default to 30s and document the env var. Only pages that keep
running past the deadline are affected; fast pages finish and return well
before it, so the larger budget costs them nothing, and the per-phase
watchdog (armed at deadline + 1s) still bounds a real synchronous hang.
Very heavy SPAs on slow networks can raise it further with
OBSCURA_SCRIPT_DEADLINE_MS, paired with a matching client nav timeout.

Fixes #361.
2026-07-09 11:07:34 +02:00
SGavrl
f5d7e11692 fix(cli): pass worker proxy via env, not argv (credential leak) (#370)
obscura serve --workers N --proxy ... spawned each worker with the proxy
as a --proxy CLI flag, so the plaintext credential was visible to any
local user via ps / /proc/<pid>/cmdline (issue #366). scrape already
avoids this by passing the proxy in OBSCURA_PROXY, which is only readable
by the owner.

Pass the proxy to serve workers via OBSCURA_PROXY too, and have the serve
command read that env as a fallback when no --proxy flag is given. The
worker argv no longer contains the credential.

Fixes #366.
2026-07-09 10:52:06 +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
8f26dfa331 fix(cli): make --dump text extraction iterative (deep-DOM crash) (#368)
extract_readable_text recursed once per DOM nesting level, so a deeply
nested page overflowed the call stack and aborted the process (exit 134)
on --dump text. This is the CLI counterpart of the serialize/textContent
walks made iterative in obscura-dom; that fix did not cover this function.

Rewrite it as an iterative DFS over an explicit work stack, with a
Newline work item for a block element's trailing newline so output is
byte-for-byte unchanged, plus the same defense-in-depth node cap the DOM
walkers use. Relates to #362.
2026-07-09 10:49:44 +02:00
Andrey Mnatsakanov
b662ff8d72 fix(dom): make serialize, textContent, and import iterative (#367)
serialize_node/serialize_children, collect_text_inner (textContent), and
import_node_from recursed once per DOM nesting level with no depth cap, so a
deeply nested tree overflowed the thread stack and aborted the process. A
stack overflow is a hard abort that op_dom's catch_unwind cannot recover, so
one hostile page could take down the worker. descendants() was already made
iterative and capped for exactly this reason; these paths were missed.

Rewrite all three as iterative walks over an explicit heap stack and add the
same defense-in-depth cap so a cyclic graph stops instead of spinning.
Serialized output and traversal order are unchanged. Adds tests that build a
100k-deep tree and exercise each path.

Closes #364.
2026-07-09 10:47:25 +02:00
SGavrl
5c3d560d8e test(js): lock script-error isolation for the babel-polyfill case (#358)
Regression test for #355: an uncaught `throw` in one inline <script> must
not prevent later independent <script>s from running. This was fixed by
the #147 poison-guard (present on main) but reported again against 0.1.9
with an explicit throw; this pins the exact scenario so it cannot regress.

Closes #355.
2026-07-07 09:35:49 +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
14286e7ad6 Update README.md 2026-07-06 23:25:37 +02:00
SGavrl
205eefaf6b Added sponsor logo 2026-07-06 21:15:57 +00:00
SGavrl
ca71ce3c2d docs: add Integrations section linking the Hermes agent plugin (#354)
Point users at hermes-plugin-obscura, which runs Hermes agent browser
tasks on Obscura over CDP as a local backend.
2026-07-03 14:20:54 +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
b5039a8a7f feat(cli): add --file and --concurrency batch mode to fetch (#352)
fetch handled one URL at a time, so checking many raw resources meant
wrapping it in an external loop. Add --file <path> to read newline-
delimited URLs (- for stdin; blank lines and # comments skipped) and
--concurrency <N> to fetch them in parallel bounded by a semaphore.

Batch mode is raw HTTP only (--dump original, the default in this mode)
and prints one JSON status line per URL (url, ok, status, content_type,
bytes, elapsed_ms, or error) in input order. Rendered/DOM batch work
still belongs to scrape. A single positional URL is unchanged.

Fixes #349.
2026-07-03 13:25:17 +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
SGavrl
99d95fcdb4 Update README.md 2026-07-03 10:47:53 +02:00
SGavrl
1042d646af Add sponsor logo 2026-07-03 08:47:01 +00:00
SGavrl
016387021b feat(js): implement WebCrypto SubtleCrypto secret-key algorithms
feat(js): implement WebCrypto SubtleCrypto secret-key algorithms
2026-07-01 16:03:31 +00:00
SGavrl
f72234d99e fix: harden comment serialization and DOMSnapshot recursion depth
fix: harden comment serialization and DOMSnapshot recursion depth
2026-07-01 16:02:55 +00:00
SGavrl
e7300c5df7 fix(cdp): make DOMSnapshot walk iterative to bound recursion depth
The DOMSnapshot walk recursed one frame per nesting level. The MAX_NODES
cap bounds the node count but not the depth, so a deeply nested linear
chain (script can build thousands of nested elements) could overflow the
stack before the count guard triggered. Rewrite it with an explicit stack
so depth lives on the heap. Pre-order traversal and the MAX_NODES cap are
preserved.

Fixes #341.
2026-07-01 15:58:49 +00:00
SGavrl
4603c05ab2 fix(dom): neutralize "-->" when serializing comment contents
The comment serializer wrote its contents verbatim. The HTML parser can
never produce a comment whose data contains "-->" (it ends the comment),
but script can via document.createComment("a-->b"). Serializing that
verbatim closed the comment early and let the trailing text escape into
markup. Replace "-->" with "--&gt;" so the output round-trips as a single
comment.

Fixes #342.
2026-07-01 15:58:49 +00:00
SGavrl
ae684fae9c fix(cdp): expose main-resource response body and metadata on navigation 2026-07-01 07:02:32 +00:00