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.
This commit is contained in:
SGavrl authored and h4ckf0r0day committed 2026-05-14 23:15:37 +02:00
1 parent 09df3050f7
commit 9edde41faf
1 file changed
+38 -20
+38 -20
View File
@@ -11,6 +11,22 @@ use crate::lifecycle::LifecycleState;
#[cfg(feature = "stealth")] #[cfg(feature = "stealth")]
use obscura_net::StealthHttpClient; use obscura_net::StealthHttpClient;
/// Returns true when a JS-initiated navigation would step from a
/// non-file scheme into a file: URL. We treat that move as an SOP
/// violation because the existing realm survives the navigation and
/// can read the new document's body.
fn cross_scheme_to_file(from: &str, to: &str) -> bool {
let to_is_file = Url::parse(to)
.map(|u| u.scheme().eq_ignore_ascii_case("file"))
.unwrap_or(false);
if !to_is_file {
return false;
}
Url::parse(from)
.map(|u| !u.scheme().eq_ignore_ascii_case("file"))
.unwrap_or(true)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NetworkEvent { pub struct NetworkEvent {
pub request_id: String, pub request_id: String,
@@ -113,27 +129,15 @@ impl Page {
self.http_client.fetch(url).await self.http_client.fetch(url).await
} }
fn init_js(&mut self) { fn init_js(&mut self) {
// Drop any existing runtime so the JS realm starts clean on
// every navigation. The old code reused the V8 isolate and
// only re-bound `globalThis.document`, leaving window.onload,
// custom window properties and event handlers from the prior
// page in place. That made it possible for a page to set
// attacker-controlled state, trigger a navigation, and then
// run code in the next document's context.
if self.js.is_some() { if self.js.is_some() {
let url_str = self.url_string(); let _ = self.js.take();
let title = self.title.clone();
let dom = self.dom.take();
let js = self.js.as_mut().unwrap();
js.set_url(&url_str);
js.set_title(&title);
if let Some(d) = dom {
js.set_dom(d);
}
let _ = js.execute_script("<reset>",
"_cache.clear(); globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; \
_iframeRegistry.length = 0; globalThis.length = 0; \
globalThis._formValues = {}; globalThis._formChecked = {}; \
globalThis._eventRegistry = {}; \
globalThis.document = new Document(+_dom('document_node_id'));");
return;
} }
// Thread the BrowserContext's proxy through to the ES-module loader // Thread the BrowserContext's proxy through to the ES-module loader
@@ -424,6 +428,20 @@ impl Page {
for _chain in 0..10 { for _chain in 0..10 {
self.navigate_single(&current_url, wait_until, &current_method, &current_body).await?; self.navigate_single(&current_url, wait_until, &current_method, &current_body).await?;
if let Some((next_url, next_method, next_body)) = self.take_pending_navigation() { if let Some((next_url, next_method, next_body)) = self.take_pending_navigation() {
if cross_scheme_to_file(&current_url, &next_url) {
// SOP gate. A web page must not be able to drive
// a navigation to file:// and then read the loaded
// document. Without this an http(s) page sets
// window.onload, calls location.href = "file:..."
// and harvests document.body from a local file
// once the new document loads.
tracing::warn!(
"blocking JS-initiated cross-scheme navigation to file: {} -> {}",
current_url,
next_url,
);
break;
}
tracing::info!("JS-triggered navigation chain: {} {} -> {}", current_method, current_url, next_url); tracing::info!("JS-triggered navigation chain: {} {} -> {}", current_method, current_url, next_url);
current_url = next_url; current_url = next_url;
current_method = next_method; current_method = next_method;