Commit Graph
517 Commits
Author SHA1 Message Date
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 MnatsakanovandSGavrl 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 <[email protected]>
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 @ 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 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 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 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 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 7440b534f3 fix(cdp): stub setDownloadBehavior, detachFromTarget, activateTarget
These Chrome CDP methods logged "Unknown method" warnings. obscura runs
one page per session and does not download to disk, so there is nothing
to do, but clients that call them should get a success ack instead of a
warning. Added as no-op stubs.

Refs #340.
2026-07-01 06:55:37 +00:00
SGavrl 630f9894f6 fix(cdp): expose main-resource response body and real mime on navigation
Navigating straight to an image, PDF, or other resource could not read the
main response body over CDP, and Page.frameNavigated always reported
text/html.

Two problems: the main resource's body was stored under its internal
request id, but the client sees it as the navigation loaderId (Chrome's
requestId === loaderId convention), so Network.getResponseBody(loaderId)
missed. And binary bodies were stored as UTF-8-lossy text, corrupting the
bytes. Alias the stored body to the loaderId, store non-text-like content
types (images, PDF, octet-stream) base64, and report the response's real
Content-Type in frameNavigated. HTML pages are unchanged.

Fixes #340.
2026-07-01 06:55:37 +00: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 1826570927 fix(cli): bind multi-worker load balancer to --host
The single-worker serve path honored --host, but run_multi_worker_serve hardcoded the load balancer to 127.0.0.1. With --host 0.0.0.0 and --workers >1 (the common Docker setup) the balancer bound loopback only, so the mapped port was refused from outside the container. Bind the balancer to the requested host. Workers stay on loopback and are reached only by the balancer.

Closes #336.
2026-06-30 08:10:54 +00:00
SGavrl 9837262af8 fix(cli): bind multi-worker load balancer to --host
The single-worker serve path honored --host, but run_multi_worker_serve
hardcoded the load balancer to 127.0.0.1. With --host 0.0.0.0 and
--workers >1 (the common Docker setup) the balancer bound loopback only,
so the mapped port was refused from outside the container. Bind the
balancer to the requested host. Workers stay on loopback and are reached
only by the balancer.

Fixes #336.
2026-06-30 08:05:48 +00:00
SGavrl 50ef1eecbd fix: SVGAnimatedString, :has() selectors, and cookie persistence on serve 2026-06-29 11:00:22 +00:00
SGavrl b63125faed cdp: flush cookies on SIGTERM in serve (#333)
serve --storage-dir saved cookies only on Ctrl-C (SIGINT). docker stop
and kill send SIGTERM, which was not caught, so the save_cookies() call
at shutdown never ran and cookies were lost on restart. Watch SIGTERM as
well as Ctrl-C on Unix.
2026-06-29 10:48:20 +00:00
SGavrl 2840b08b56 dom: support :has() selectors (#331)
querySelectorAll / --selector silently returned nothing for :has().

Two fixes were needed:
- Enable :has() parsing: the selectors crate gates it on the parser's
  parse_has() (default false), so a:has(p) failed to parse and the error
  was swallowed into an empty match set.
- Fix OpaqueElement stability: opaque() was keyed off the transient
  DomElement (which is Copy and gets a fresh stack address each traversal
  step), so the same node produced a different id every call. :has anchor
  matching compares the anchor's opaque against the element reached by
  walking up the tree, so it never matched. Key opaque() off the node's
  stable slot in the tree's Vec instead.
2026-06-29 10:48:20 +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
GabrielandDavidCarliez f196b35b39 net: validate Set-Cookie Domain against the origin
CookieJar::set_cookie and set_cookie_from_js started from the request host but
blindly overwrote it with any Domain attribute, so a response from
attacker.test could plant a cookie scoped to victim.test, which was then sent
on later requests to victim.test. The same sink backs document.cookie, HTTP
Set-Cookie, and CookieStore::set.

Add resolve_cookie_domain: with no Domain the cookie is host-only (exact host
only); a Domain attribute is honored only when it domain-matches the origin
(equal or a parent domain) and is not an obvious public suffix, otherwise the
attribute is ignored and the cookie is stored host-only on the origin. Track
host_only on CookieEntry so host-only cookies are not sent to subdomains.

A full public suffix list is not bundled, so multi-label public suffixes
(co.uk, github.io) are not rejected; the domain-match check still blocks the
reported cross-domain attack, and single-label suffixes (com, local) are.

Tests cover the cross-domain rejection for both HTTP and document.cookie,
public-suffix rejection, host-only subdomain scoping, and that legitimate
parent-domain cookies still work.

Co-authored-by: DavidCarliez <[email protected]>
2026-06-25 22:05:06 +02:00
SGavrl bd39512ffb api: re-validate intercept URL rewrites against the SSRF gate
A Continue resolution that rewrites the request URL applied the new URL
after validate_fetch_url had already run on the original, so a rewrite
could reach a private or internal address the original request was
blocked from. Run the overridden URL back through validate_fetch_url,
matching how redirects are re-validated, and return a blocked response
if it fails.

Also fix a doc comment that ended up attached to the new fetch_response
helper instead of stealth_fetch_all.
2026-06-24 23:06:54 +00:00
SGavrl c19a21c142 api: honor interception Continue overrides and fix pre-navigation enable
op_fetch_url ignored the url/method/headers/body fields on
InterceptResolution::Continue, so the interception channel could pass, mock, or
block a request but not rewrite it. Apply them: shadow url/method/body and
derive custom_headers from the override after the channel resolves Continue.

Also fix enable_interception() called before the first navigation: init_js
propagated intercept_tx to the new runtime but not intercept_enabled, so the
runtime started with interception disabled and op_fetch_url never intercepted.
Propagate intercept_enabled in init_js as well.

Add a test that rewrites a fetch's url via Continue and asserts the rewritten
response is captured.
2026-06-24 21:51:21 +00:00
SGavrl 1f5066fc30 api: add request/response interception API to Page (#306)
Expose the interception infrastructure that already existed internally but was
private, so embedders can intercept, observe, mock, or block JS fetch()/XHR
without forking or unsafe casts.

On obscura::Page:
- add_preload_script: inject JS before any page <script> (CDP
  Page.addScriptToEvaluateOnNewDocument). The field already ran the scripts;
  this only adds the method.
- enable_interception: returns a receiver for every JS fetch()/XHR request;
  resolve each through its resolver with InterceptResolution::{Continue,
  Fulfill, Fail} to pass, mock, or block (CDP Fetch.enable equivalent).
- on_request / on_response: passive callbacks. on_response is the main path for
  capturing API response payloads from SPAs.

on_request/on_response previously fired only for navigation; op_fetch_url now
invokes them for JS fetch()/XHR too (both reqwest and stealth paths), gated on
a non-empty callback vector so the common no-callback case pays nothing.

Re-export InterceptedRequest, InterceptResolution, RequestInfo, ResourceType,
RequestCallback, ResponseCallback, Response from the obscura crate.

Integration test tests/interception.rs exercises all three against a local echo
server.
2026-06-24 21:36:01 +00:00
SGavrl 8c14c4393d cli: make --stealth a global flag, available on every subcommand
--stealth was a per-subcommand flag defined separately on fetch, serve,
and mcp, and missing from scrape entirely. You could not enable stealth
for parallel scraping, and `obscura --stealth fetch` (flag before the
subcommand) errored.

Promote --stealth to a single global flag like --proxy, so it is valid
before or after any subcommand and applies uniformly to fetch, serve,
scrape, and mcp. scrape now forwards it to its workers via OBSCURA_STEALTH
(mirroring OBSCURA_PROXY), and the bare `obscura` default CDP server
honors it too.

Verified: navigating directly to a blocklisted tracker renders 55503
chars without stealth and 39 (blocked) with it, identically on fetch and
scrape. Obstacle course 33/33.
2026-06-24 09:46:10 +00:00
Gabriel d2206fea84 stealth: fingerprint consistency, scripted-request TLS, FormData
**Fingerprint consistency (stealth)** The stealth TLS, navigator, and WebGL surfaces previously disagreed. The wreq client reported Windows Chrome but navigator reported Linux, and WebGL returned Direct3D11 renderers for a Linux UA — a combination no real browser ever produces. Everything now presents one consistent Windows Chrome 145 identity: the HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, navigator.platform, userAgentData, and WebGL renderer all agree. userAgentData brands are derived from the Chrome major version via Chromium's GREASE algorithm instead of a single hardcoded token.

**sec-ch-ua client hints on the default (non-stealth) path** The plain navigation client hardcoded `sec-ch-ua-platform: "Linux"` and Chrome 149 brands regardless of which profile was selected. Both headers are now derived from the actual User-Agent using the same GREASE algorithm, so non-stealth requests are also internally consistent.

**Route scripted fetch()/XHR through the stealth TLS client** In stealth mode, JS `fetch()` and `XMLHttpRequest` were still going through the plain reqwest/rustls stack, so subresource requests (sensors, WAF challenge verify calls) arrived with a non-browser TLS fingerprint. They now share the navigation's Chrome TLS fingerprint and sec-ch-ua client hints. The non-stealth path is unchanged.

**FormData serialization in fetch() — the booking.com fix** Passing a `FormData` as a `fetch()` or `XHR.send()` body sent the literal string `"[object Object]"` instead of a multipart payload. booking.com's 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. `FormData` is now serialized to a proper `multipart/form-data` body with a generated boundary; `URLSearchParams` gets `application/x-www-form-urlencoded`. XHR routes through `fetch()` so it is covered too.

**navigator.permissions.query defaults** `permissions.query` returned `"granted"` for every permission. Real Chrome defaults camera, microphone, geolocation, and midi to `"prompt"`. Fixed.

**SHA-512/224 and SHA-512/256 in crypto.subtle.digest** Added FIPS 180-4 truncated SHA-512 variants to the Rust op and the JS allowlist. WAF challenge scripts that use these algorithms now get real output instead of a NotSupportedError.

**location updates synchronously on navigation, Blob-URL Worker race fixed**
2026-06-23 18:14:44 +00:00