Commit Graph
15 Commits
Author SHA1 Message Date
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 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 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
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
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
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
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