Commit Graph
56 Commits
Author SHA1 Message Date
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
Ben Younes f3c783f6f5 fix(net,js): route op_fetch_url + ES module loader through proxy_url (#139) (#151)
Fixes the remaining two paths from #139: JS-level fetch()/XHR
(op_fetch_url) and dynamic ES module imports (ObscuraModuleLoader) both
built their reqwest clients with `Client::builder().build()` — no
proxy — so every JS-initiated network call silently bypassed
BrowserContext.proxy_url. (The third path, StealthHttpClient, was
already fixed in main: page.rs:61 uses with_proxy.)

Changes:

* obscura-net: expose ObscuraHttpClient::proxy_url() so callers outside
  the crate can route their own reqwest requests through the same
  upstream proxy. The field was already there — just lacked an accessor.

* obscura-js/module_loader: add `proxy_url: Option<String>` and
  `with_proxy(base_url, proxy_url)`. ObscuraModuleLoader::new keeps the
  pre-#139 direct-connection behaviour. load() builds a reqwest client
  per import, threading the proxy and bubbling up "Invalid module proxy"
  on bad URLs.

* obscura-js/ops.rs: replace the process-wide
  `static SHARED_HTTP_CLIENT: OnceLock<reqwest::Client>` (which could
  never be reconfigured after first call) with a per-request
  `build_request_client(proxy_url)` that pulls the configured proxy out
  of SharedState.http_client.proxy_url() each call. Per-request build
  cost is ~1ms vs the network round-trip, and we no longer have a
  global singleton that lies about its config.

* obscura-js/runtime: add ObscuraJsRuntime::with_base_url_and_proxy.
  with_base_url delegates to it with None so existing tests / callers
  are unaffected.

* obscura-browser/page.rs: thread context.proxy_url through to
  with_base_url_and_proxy when constructing the JS runtime.
2026-05-14 08:25:55 +00:00
cpp1992andhandsome.cheng c6f6ce2cc2 fix(stealth): pass proxy config to StealthHttpClient (#141)
When --stealth is enabled, StealthHttpClient was created without
the proxy URL from --proxy, causing all stealth-mode requests to
bypass the proxy.

Also convert SOCKS5 proxy URL to HTTP when passing to wreq-based
StealthHttpClient, because wreq does not support SOCKS5 protocol
(Clash mixed port supports both protocols on the same port).

Fixes proxy connectivity for 'obscura serve --stealth --proxy socks5://...'

Co-authored-by: handsome.cheng <[email protected]>
2026-05-13 22:25:18 +02:00
Mike Mooring 9e3e1e4ef7 fix(cdp): five Playwright drop-in compatibility patches
Brings Obscura's CDP server in line with what Playwright's
`chromium.connectOverCDP()` expects. Each fix is small and surgical;
together they make `connect → newContext → newPage → goto → page.title()`
work end-to-end against `https://example.com`.

1. **server.rs — emit pending events before command response.**
   Chromium CDP semantics require side-effect events (e.g.
   `Target.targetCreated` + `Target.attachedToTarget` from
   `Target.createTarget`) to arrive *before* the originating command's
   response. Playwright's wire reader assumes this ordering — it
   resolves the response promise on the response and then synchronously
   looks up the freshly-created session. Reversing the order makes that
   lookup miss and the page hang at `newPage()`.

2. **domains/browser.rs — accept Browser.setWindowBounds as a no-op.**
   Playwright calls `Browser.setWindowBounds` during teardown. Returning
   "Unknown Browser method" causes Playwright to treat the target as
   broken and tear it down mid-test, surfacing as "Frame has been
   detached" on the next `page.title()`.

3. **obscura-browser/page.rs — frame_id == target_id for main frame.**
   Was `format!("{id}.1", id)`. Playwright's frame manager indexes
   frames by frame id and looks up the main frame by target id, so the
   `.1` suffix made every main-frame lookup miss.

4. **domains/page.rs + dispatch.rs — track isolated worlds and re-emit
   them post-navigation.** Playwright registers a utility world via
   `Page.createIsolatedWorld` and expects `Runtime.executionContextCreated`
   for each registered world after every navigation. Without re-emission,
   the utility-world handle goes stale post-`goto` and `page.title()`
   hangs waiting for the world's execution context. Falls back to the
   legacy `__puppeteer_utility_world__24.40.0` name when no worlds have
   been registered, preserving prior Puppeteer support.

5. **obscura-js/runtime.rs — strip trailing semicolons before wrapping.**
   Playwright's utility-script expression is an IIFE ending in `})();`.
   Both `wrap_expression` (CDP `Runtime.evaluate`) and `evaluate_for_cdp`
   (the `meta_code` IIFE) embed the user expression inside `({EXPR})` /
   `return ({EXPR});`. With the trailing `;` left in, this produces
   `({...;})` — a SyntaxError. Parse errors are not catchable, so the
   surrounding try/catch never fires and the call silently returns
   `undefined`, surfacing as `page.title() === ""`.

Verified end-to-end against `https://example.com`:
  [probe] connect OK
  [probe] newContext OK
  [probe] newPage OK
  [probe] goto OK
  [probe] title = Example Domain
2026-04-24 23:27:18 -07:00
h4ckf0r0day 8749725b45 Initial Release 2026-04-13 12:30:28 +02:00