Commit Graph
24 Commits
Author SHA1 Message Date
SGavrl 682431f766 fix(cdp): allocate fresh execution context ids per isolated world (#192) (#200)
Page.createIsolatedWorld and do_navigate both hardcoded context id 100
  for every isolated world emission. After the second navigation Playwright
  expected a fresh incrementing id (101, 102) while obscura kept re-using
  100. Runtime.evaluate then failed with 'Cannot find context with
  specified id: 101' because the id was never registered in
  valid_context_ids and the client's bookkeeping had diverged from the
  server's.

  Added next_isolated_context_id counter on CdpContext (starts at 100) and
  a next_isolated_context() helper that claims an id, registers it in
  valid_context_ids, and increments. Both call sites now use it.
2026-05-29 17:11:10 +00:00
SGavrl 191c5ac7a2 fix(cdp): page.goto returns Response over Puppeteer and Playwright (#189) (#190)
Two correlation invariants real Chrome holds were missing, so the
  nav Response was never attached to goto().

  requestId === loaderId for the main Document event. Both clients
  gate isNavigationRequest() on this; we were emitting a synthetic
  per-page id.

  Network.requestWillBeSent for the Document fires before
  Page.frameNavigated. Playwright's FrameManager needs the request
  present as pendingDocument before the frame commits, otherwise
  currentDocument lands with request=undefined.

  Subresource events and the Document's responseReceived /
  loadingFinished stay where they were.

  Verified end-to-end on both clients against example.com (200),
  google.com (200), httpbin/404 (404).
2026-05-27 20:58:29 +00:00
SGavrl d15cac6149 fix(js): Element.attributes, on* event defaults, per-nid getBoundingClientRect (#45) (#186)
Three DOM-completeness gaps that broke jQuery 1.11.3 init and Playwright
  actionability:

  1. Element.attributes was undefined, so legacy IE feature-detect code
     like div.attributes[eventName].expando threw TypeError. Added a
     NamedNodeMap-like backed by a new attribute_names DOM op (length,
     numeric index, string name lookup, getNamedItem/setNamedItem,
     iterable).

  2. Standard on* event handler properties were missing from window, so
     ('on' + ev) in window returned false and forced jQuery into the
     legacy IE branch. Initialised the 79 HTML-spec on* handlers to null.

  3. getBoundingClientRect returned the same {x:8,y:8,width:100,height:20}
     literal for every element, so Playwright's hit-testing couldn't tell
     elements apart. Replaced with a deterministic synthesis from _nid
     mapped into a 12-column grid sized to a 1280x720 viewport. Still not
     pixel-accurate (no layout engine), but stable across reads and
     distinct per node.
2026-05-25 22:08:49 +02:00
SGavrl 8a8c767338 fix(net): use bundled webpki roots in stealth client (#184) (#185)
set_default_paths only resolves OpenSSL's compile-time CA dirs, which
  exist on Linux but are empty on Windows. The stealth client's cert store
  ended up empty there and every TLS handshake failed with
  CERTIFICATE_VERIFY_FAILED inside BoringSSL handshake.cc.

  CertStore::default() uses wreq's bundled webpki-root-certs (Mozilla's
  trust list), which behaves the same on every platform and removes the
  dependency on a system CA store.
2026-05-25 19:24:56 +02:00
SGavrl 52fcf2a35a fix(net,browser): detect response charset, decode non-UTF-8 pages (#182)
obscura was calling String::from_utf8_lossy on every response body, which
silently corrupts every non-UTF-8 page (GBK, Big5, Shift-JIS, Windows-125x,
EUC-KR, ISO-8859-x). Whole language families came through as replacement
characters.

Add an encoding module (obscura-net::encoding) that picks the decoder
the way real browsers do per HTML5 § 8.2.2.4:

  1. HTTP Content-Type charset=... parameter (authoritative when present)
  2. <meta charset> or <meta http-equiv=Content-Type> sniffed from the
     first 1KB of the body (HTML only)
  3. UTF-8 fallback

Use it in:
  - obscura-net Response::text (signature changed: String, not Result)
  - obscura-browser page.rs HTML body decode (the primary scrape path)
  - obscura-browser page.rs JS body decode
  - obscura-browser page.rs CSS body decode

For JS / CSS we deliberately skip the meta sniff: those bodies are not
HTML and any '<meta charset=...>' substring inside them is not a charset
declaration.

Adds 7 unit tests for the encoding module covering header precedence,
quoted charset values, meta charset sniff, http-equiv form, the 1KB
sniff window, and a round-trip decode of GBK bytes. Adds a Python
feature test exercising file:// + meta charset and a live HTTP server
serving Shift_JIS with the wrong meta tag.

Closes #113.
2026-05-24 20:14:53 +00:00
SGavrl f4f49ebab4 fix(cdp): unblock puppeteer connect, bound Runtime.evaluate, full TargetInfo (#181)
Three independent gaps that hurt browser-based MCP clients connecting
to obscura's CDP endpoint:

- Runtime.enable returned "No page" when called before a session was
  attached. puppeteer-extra's FrameManager.initialize fires it on the
  browser-level WebSocket BEFORE any page target exists, so the
  standard puppeteer.connect / browser.newPage flow died on connect.
  Now reply with {} when no session is attached and let the next
  Target.attachToTarget emit executionContextCreated.

- Runtime.evaluate had no per-call timeout. A long synchronous JS
  expression held the global V8 lock indefinitely and starved every
  other CDP command on the session. Honor the CDP `timeout` parameter
  and default to 30s (Chrome's protocolTimeout). Returns a clear error
  on expiry so client-side deadlines fire cleanly instead of hanging.

- Target.getTargetInfo for the browser target omitted canAccessOpener.
  Strict CDP clients (chromiumoxide) panic when the field is missing.
  Fill it in (false for the browser target itself).

Partial fix for #122. The remaining items (networkIdle hard-deadline
on SPAs, /json/list hardcoded data, docs on cheap-vs-queued methods)
need bigger or non-code changes and are deferred.
2026-05-24 20:14:35 +00:00
SGavrl 71b9b4fbab fix(js,dom): scope element queries, real timers, form/prepend/equal (#180)
- Element.querySelector / querySelectorAll scope to the receiver subtree
  via a new query_selector_scoped Rust op, not always document root.
  Same for DocumentFragment.
- setTimeout respects its delay argument via a new async op_sleep.
  setInterval is a real repeating timer instead of an alias to setTimeout.
- document.forms / images / links return live querySelectorAll results
  instead of hardcoded [].
- HTMLFormElement subclasses Element with .elements / .length, and form
  nodes are wrapped as HTMLFormElement so instanceof works. submit()
  stays on Element.prototype to preserve the existing submit-event
  dispatch and form-data navigation path.
- Element.prepend has a real implementation.
- Node.isEqualNode does a structural compare (nodeType, nodeName,
  nodeValue, attributes, recursive children) instead of nid identity.
- Fix long-standing insert_before arg-order bug: bootstrap.js was
  passing (parent, new, ref) but _dom only forwards two args, so ref
  was silently dropped. Pass (new, ref) directly. Same fix in
  replaceChild.

Adds 3 selector unit tests and 6 runtime regression tests covering
the JS shim fixes.

Closes #105.
2026-05-24 20:14:17 +00:00
SGavrl d4d749d850 fix(mcp): allow browser MCP clients through CORS preflight (#179)
The OPTIONS preflight hardcoded Access-Control-Allow-Headers to just
Content-Type. Any browser MCP client sending mcp-protocol-version (per
the MCP spec) or Authorization / X-API-Key (typical for hosted
deployments) failed the preflight and the browser blocked the actual
request.

Add those three to the allow-list and a 24h Max-Age so browsers don't
re-preflight every call. Adds an integration test that drives the
server with a real preflight request.

Closes #175.
2026-05-24 20:14:01 +00:00
SGavrl a3b21bbb70 fix(cdp,js): playwright actionability stubs and objectId resolve (#178)
Playwright fails locator actions on Obscura for two avoidable reasons
that are separate from the missing layout engine. Both have trivial
fixes that unblock the actionability polling loop.

- Element.checkVisibility(opts) was undefined. Playwright >= 1.25 calls
  it before every input event and retries until its action timeout if
  the method isn't a function. Add a stub that returns true. Without a
  layout pass we can't compute it for real, but a permissive stub still
  beats the undefined-function path.

- ARIA reflection properties were missing on Element (role, ariaLabel,
  ariaSelected, ariaExpanded, ariaHidden, ariaChecked, ariaDisabled,
  ariaRoleDescription). Playwright's getByRole and getByLabel locators
  read them. Reflect through to the underlying aria-* attributes for
  both read and write.

- DOM.getBoxModel and DOM.getContentQuads ignored the objectId param.
  Playwright passes only objectId (from a prior DOM.resolveNode), and
  the handlers silently defaulted to node 0, so dispatchMouseEvent
  always clicked the wrong element. Extract a resolve_node_id helper
  that honors nodeId, backendNodeId, and objectId in that order.

- Page.captureScreenshot and Page.captureSnapshot now return a clear
  "not supported, use a real Chromium for the screenshot leg" error
  instead of the generic "Unknown Page method" fallback. Matches the
  pattern already in place for Page.printToPDF.

Partial fix for #45. The architectural blocker (no layout engine, so
multi-element click coordinates are wrong) is unchanged.
2026-05-24 20:13:43 +00:00
SGavrl e0af9fc44f fix(cdp): emit canAccessOpener in all TargetInfo payloads, add aarch64 cross-compile CI
Merges PR #165 and PR #172.

PR #165: The CDP spec marks canAccessOpener as a required boolean on
TargetInfo but Obscura was omitting it. Strict Rust clients like
chromiumoxide reject the event entirely and panic with "Created target
not present" when the field is absent. Added canAccessOpener: false
to every TargetInfo emitted by setDiscoverTargets, getTargets,
createTarget, attachToBrowserTarget, attachToTarget, and getTargetInfo.

PR #172: Added a cross-compile matrix entry for aarch64-unknown-linux-gnu
running on ubuntu-22.04 x86_64, producing obscura-aarch64-linux-cross.
This is a fallback for when GitHub ARM runners are unavailable without
removing the native ARM build.
2026-05-24 15:22:30 +00:00
SGavrl 1950048138 fix(browser,cdp,js): about:blank navigation, real getBoxModel/getContentQuads, expose _wrap and self
- about: URL scheme now handled in navigate_single instead of
  hitting validate_url and failing with "Forbidden URL scheme"
- DOM.getBoxModel calls getBoundingClientRect via JS instead of
  returning hardcoded pixel coords
- DOM.getContentQuads added (was missing, needed by Playwright click)
- globalThis._wrap exposed so CDP eval code can look up nodes by id
- globalThis.self = globalThis for webpack/bundler worker detection
2026-05-18 10:11:54 +00:00
SGavrl 913e6a4865 fix(browser,cdp): correct document lifecycle ordering and add missing DOM methods
document.readyState was hardcoded to "complete" so scripts checking
readyState === 'loading' always took the wrong branch. Fixed by using a
JS global (__documentReadyState__) that Rust sets to "loading" before
any scripts execute, "interactive" before DOMContentLoaded fires, and
"complete" after load.

document.addEventListener and document.dispatchEvent on Document were
no-ops, so DOMContentLoaded listeners registered by inline scripts were
never called. Added proper listener storage and dispatch on Document.

Adds document.getElementsByName() (was missing entirely).

Page.reload is now implemented (re-navigates to the current URL),
resolving "Unknown Page method: reload" errors from Playwright.

data: URL navigation now works, fixing Playwright's goto("data:text/html,...")
pattern that previously errored with "invalid format".

Page.navigate logic extracted into do_navigate() so reload can reuse it.

Fixes #137, #146 (SPA bootstrap lifecycle ordering).
2026-05-18 09:50:23 +00:00
SGavrl e371e942fc fix(js): re-validate redirect targets in op_fetch_url
GHSA-8v6v-g4rh-jmcm: op_fetch_url validated the initial URL through
validate_fetch_url, but reqwest auto-followed up to 10 redirects with
no further checks. An attacker-allowed origin could 302 to
http://127.0.0.1 (or any private/internal address) and read the body
back to page JS. Real-world impact: AWS instance metadata, kube
debug consoles, internal admin endpoints with permissive CORS all
become readable from a page hosted anywhere on the open web.

Disable reqwest's auto-redirect and follow manually. Apply
validate_fetch_url to every redirect target before requesting it.
Cap the chain at 10 hops to match prior behaviour. Browser-standard
method/body rules apply on cross-method redirects (301/302/303
downgrade to GET with no body, 307/308 preserve).

Verified vs unpatched build (pr69):
  - Patched: attacker-page -> 302 -> http://127.0.0.1:N is blocked
    with "Redirect to forbidden URL blocked: Access to private IP..."
    before the internal hop runs. Legitimate 3-hop redirect chain
    between allowed origins still completes.
  - Unpatched: internal-service body leaks through to page JS via
    fetch().then(r => r.text()).
2026-05-16 15:26:58 +00:00
SGavrl 3158a1fd31 fix(cdp): gate Target.createTarget file:// like Page.navigate
GHSA-q55h-vfv9-qcr5 patched Page.navigate to refuse file:// URLs
unless --allow-file-access was set, but Target.createTarget accepted
the same scheme and called page.navigate(url) directly, bypassing
the policy. A CDP client could call Target.createTarget {url: file://...}
then Runtime.evaluate to read document.body.textContent and recover
arbitrary local files.

Apply the same gate at the Target.createTarget entrypoint. Pull
the file-scheme detector into a shared util module so both domains
stay in lock-step instead of one drifting from the other.

Verified blocked vs the unpatched build (pr69):
  - Patched: Target.createTarget {url: file:///tmp/secret} errors
    "Target.createTarget to file:// is disabled. ..." before any
    navigation runs.
  - Unpatched: same call returns the file contents via
    Runtime.evaluate {expression: "document.body.textContent"}.
2026-05-16 15:26:58 +00:00
SGavrl 935cb5c0da fix(cli,js): add --version flag and script-error-resilience test (#147)
Two improvements while investigating #147:

1. `obscura --version` / `-V` now print the CARGO_PKG_VERSION instead of
   erroring out. The issue reporter explicitly called out that there was
   no version flag, and the existing `obscura serve` banner is the only
   way to discover the version.

2. New regression test in obscura-js verifies that a TypeError thrown
   from one script does not poison the runtime: subsequent scripts and
   DOM queries must continue to work. The reporter's symptom was that
   offside.js crashing collapsed --dump text output to 1 byte; that
   cascade should never happen and this test pins it down.

The classList error itself is offside.js's own bug (it indexes into the
querySelectorAll result without checking for an empty array) and only
fires when the .slideout-navigation element is absent from the DOM.
On current main that element parses correctly and offside.js runs
without error against a captured copy of the affected page.
2026-05-16 15:11:18 +00:00
SGavrl 8cb0cb429b fix(cli): refuse --stealth + socks5:// proxy instead of silent breakage (#160)
The previous workaround in page.rs rewrote socks5:// to http:// before
handing the proxy to the stealth client. That works for Clash-style
mixed-mode proxies, but breaks plain SOCKS5 servers like `ssh -ND`,
producing a confusing `TunnelUnexpectedEof` deep in the network layer.

The stealth client is backed by wreq, which does not speak SOCKS5,
so silently substituting schemes can never be correct in general.

Refuse the combination at CLI startup with a clear message that tells
the user what to do (use --proxy http://... or drop --stealth) and
revert the rewrite in Page::new. SOCKS5 without --stealth still works
through the reqwest path.
2026-05-15 21:23:53 +00:00
SGavrl b2b41529c4 ci: add aarch64-linux release binary (#127)
Uses GitHub's native ubuntu-22.04-arm runner so it's a native build
matching the same glibc 2.35 floor as the x86_64 target. README gets
the matching install snippet.
2026-05-15 21:10:29 +00:00
SGavrl aaeaf8f662 chore: drop author working notes that landed with #69 2026-05-15 20:44:46 +00:00
SGavrl b07863b5ce chore: drop author working notes that landed with #69 2026-05-15 20:44:32 +00:00
SGavrl 9c6dec4086 chore: drop author working notes that landed with #69 2026-05-15 20:44:21 +00:00
SGavrl 3a6f3e6c22 Update README.md
cleaning up
2026-05-15 19:51:59 +00:00
SGavrl 18dc796df0 fix(cdp): gate file:// navigation behind --allow-file-access
GHSA-q55h-vfv9-qcr5. Any process able to reach the CDP port could
send Page.navigate {"url":"file:///etc/shadow"} and read arbitrary
local files (the process's UID's worth of read access). When obscura
runs as root inside Docker or as a systemd service that translates
straight into /etc/shadow, /root/.ssh/id_rsa, /proc/1/environ, etc.

Validate at the CDP boundary, not in obscura-net, so direct CLI
callers ("obscura fetch file://...") keep working. A new
BrowserContext.allow_file_access flag defaults to false; Page.navigate
returns an error when the requested URL is file:// and the flag is
off. CdpContext::new_with_security threads the flag from the server
entry point, and "obscura serve --allow-file-access" opts in for
local-HTML testing workflows.

Tested:
- Default obscura serve: CDP Page.navigate {file:///etc/passwd} ->
  refused with descriptive error.
- obscura serve --allow-file-access: same call succeeds.
- obscura fetch file://... CLI path: unchanged, still reads the file.
- All earlier PoCs (GHSA-6pj9 JS->file SOP, GHSA-4789 sub-resource
  SSRF, GHSA-4789 CSS template injection) still pass.
- cargo test --workspace clean.
2026-05-14 23:46:35 +02:00
SGavrl ac0efd1798 fix(browser): patch sub-resource SSRF, CSS injection, and silent redirect cap
Three findings from GHSA-4789-rq5x-jv3c.

HIGH-1: Sub-resource fetches (<script src>, <link rel=stylesheet>) had
no scheme gate, so an http(s) page could include
<script src="file:///etc/passwd"> and the body would come back as
script source. Added subresource_allowed() that mirrors the top-level
file:// SOP rule: file: sub-resources only load when the page itself
is on file:. Off-origin schemes (data:, javascript:, etc.) are
refused regardless. Private/loopback hosts were already blocked at
the obscura-net layer; this closes the file:// hole on top of that.

HIGH-2: CSS pulled from the network was string-formatted into a JS
template literal with a three-rule escaper (backslash, backtick,
${). That escaper did not handle U+2028 / U+2029 which terminate
ECMAScript template literals, letting attacker CSS break out and
run arbitrary code in the page's V8 realm. Replaced the inline
escape with escape_for_js_template_literal() which also handles
U+2028, U+2029, null, carriage return, and every other C0 control.

LOW-2: navigate_with_wait_post() capped JS-driven navigation chains
at ten but returned Ok(()) when the cap was hit, so callers could
not tell a successful load from a redirect storm. Now returns
PageError::TooManyRedirects(REDIRECT_LIMIT).

Verified with three PoCs (sub-resource SSRF, CSS template literal
break with U+2028, and the original GHSA-6pj9-j389-226p reproducer)
plus negative tests (file:// direct read, http -> http JS chain) all
pass. cargo test --workspace clean.
2026-05-14 23:45:26 +02:00
SGavrl 9edde41faf fix(browser): block JS-driven http -> file navigation, reset realm per nav
A page on http(s) could set window.onload, assign location.href to a
file:// URL, and then read document.body from the local file because
two things lined up: obscura-net allows file: URLs, and the JS
runtime was reused across navigations so window state from the prior
origin survived into the new document's load event.

Two fixes:

1. Refuse a JS-initiated chain step when it crosses from a non-file
   scheme into file:. Direct file:// navigations from the CLI/CDP
   still work because the gate only fires on the take_pending_nav
   path. file -> file chains also still work.

2. Drop and rebuild the V8 runtime on every navigation instead of
   only rebinding globalThis.document. Even within the same origin
   we should not carry over window.onload, custom properties, or
   registered event listeners from the previous document.

Tested with the PoC from the report: pages on http://127.0.0.1.nip.io
no longer leak the contents of file:///tmp/.../pwn.txt.
2026-05-14 23:15:37 +02:00