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.
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.
Surfaces the existing LP.getMarkdown CDP machinery as a CLI flag so
text-pipeline users (LLM agents, scrapers feeding markdown to other
tools) don't need a separate HTML-to-markdown converter step.
obscura fetch --dump markdown 'https://example.com'
Layout:
- crates/obscura-js/src/markdown.rs holds HTML_TO_MARKDOWN_JS as a
pub const so every layer can share the same DOM-walker. obscura-js
is at the bottom of the dep DAG (obscura-browser -> obscura-js,
obscura-cdp -> obscura-js, obscura-cli -> obscura-js) so a single
source of truth works without a circular dependency.
- crates/obscura-browser/src/lib.rs re-exports it for ergonomics.
- crates/obscura-cdp/src/domains/lp.rs drops its inline duplicate
and calls the shared const.
- crates/obscura-cli/src/main.rs adds the `Markdown` DumpFormat
variant + dump_markdown(&mut Page) which page.evaluate's the
shared script and prints the result.
Net diff: a new 75-line markdown.rs, a 70+ line deletion in lp.rs
that's reborn as 3 lines, and ~10 lines added across the other four
files. No behavior change for `--dump html|text|links`, and
LP.getMarkdown over CDP keeps the exact same JSON shape.
Four new unit tests in obscura-js cover headings, inline emphasis +
links, ordered/unordered lists, and that <script> / <style> contents
do not leak into the output.
Generated by Ora Studio
Vibe coded by ousamabenyounes
Co-authored-by: Ora Studio <[email protected]>
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]>
obscura serve --user-agent <ua> only logged the value via tracing::info!
and never passed it to the CDP server, so every navigation kept using
the default Chrome UA string and httpbin.org/user-agent reported the
default rather than the override.
Plumb the option end-to-end:
- BrowserContext::with_full_options(id, proxy, stealth, user_agent)
initializes both BrowserContext::user_agent and the underlying
ObscuraHttpClient::user_agent RwLock at construction time so the
first navigation already sees the override.
- CdpContext::new_with_full_options forwards the UA to the default
BrowserContext.
- obscura_cdp::start_with_full_options accepts the UA and passes it
through cdp_processor.
- main.rs Serve arm calls start_with_full_options with the parsed UA.
- run_multi_worker_serve forwards --user-agent <ua> when spawning
worker subprocesses so multi-worker mode honors the flag too.
Existing two-arg start_with_options and three-arg with_options stay
as thin shims (call the full variant with None) so older callers and
the public crate API remain source-compatible.
Three new unit tests in obscura-browser cover the propagation, the
Chrome default fallback, and the legacy with_options path.
Generated by Ora Studio
Vibe coded by ousamabenyounes
Co-authored-by: Ora Studio <[email protected]>
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