fix(net): scope passive callbacks to the registering page (#414)

The on_request/on_response Vec lived on the ObscuraHttpClient shared by
every page in the context, so a callback registered on one page fired for
all pages (#408 cross-page bleed) and outlived its page.

Callbacks now live in a per-page CallbackRegistry (Arc shared with the
page's JS runtime state). Page-driven fetch paths (navigation, POST,
scripts, stylesheets, robots.txt, module loads, JS fetch/XHR in both
plain and stealth modes) pass the registry through; the HTTP client
itself is callback-free.

Adds callbacks_do_not_bleed_across_pages: page A's callback stays silent
for page B's requests and stops firing after page A is dropped.
This commit is contained in:
Jim Zhang @ wonderhoi authored and GitHub committed 2026-07-16 12:43:13 +02:00
1 parent ccfa5fb87b
commit f5dc97b131
8 files changed
+249 -108

No files matched your search

+3 -3
View File
@@ -128,9 +128,9 @@ impl Page {
}
/// Detach a request callback previously registered with `on_request`.
/// Returns true if a callback with that id was removed. Callbacks live on
/// the context's shared HTTP client, so detach them when a page or capture
/// phase ends to avoid firing for sibling pages (issue #408).
/// Returns true if a callback with that id was removed. Callbacks are
/// scoped to this page — they never fire for sibling pages and are
/// dropped with the page (issue #408).
pub fn off_request(&mut self, id: u64) -> bool {
self.inner.off_request(id)
}
+47
View File
@@ -108,6 +108,53 @@ async fn page_intercepts_and_observes_js_fetch() {
);
}
/// Issue #408 follow-up: callbacks are page-scoped. A callback registered on
/// page A must not fire for requests made by page B in the same browser
/// context, and must not survive page A being dropped.
#[tokio::test]
async fn callbacks_do_not_bleed_across_pages() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let base = spawn_echo_server();
let browser = Browser::new().unwrap();
let mut page_a = browser.new_page().await.unwrap();
let mut page_b = browser.new_page().await.unwrap();
let a_hits = Arc::new(AtomicU32::new(0));
let a = a_hits.clone();
page_a.on_request(Arc::new(move |_info| {
a.fetch_add(1, Ordering::SeqCst);
}));
// Page B navigates (navigation + inline fetch('/api')); page A's callback
// must stay silent.
page_b.goto(&base).await.unwrap();
page_b.settle(500).await;
assert_eq!(
a_hits.load(Ordering::SeqCst),
0,
"page A's on_request fired for page B's requests"
);
// Page A navigates; its own callback fires.
page_a.goto(&base).await.unwrap();
assert!(
a_hits.load(Ordering::SeqCst) >= 1,
"page A's on_request did not fire for its own navigation"
);
// Dropping page A must not leave its callback firing for page B.
let before_drop = a_hits.load(Ordering::SeqCst);
drop(page_a);
page_b.goto(&base).await.unwrap();
page_b.settle(500).await;
assert_eq!(
a_hits.load(Ordering::SeqCst),
before_drop,
"dropped page A's on_request still fired for page B's requests"
);
}
#[tokio::test]
async fn page_rewrites_request_url_via_interception() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");