feat(stealth): many stealth improvements

This commit is contained in:
Sander Spilleman authored and GitHub committed 2026-06-03 08:21:14 +00:00
1 parent 1b9be08767
commit 9ae36764da
13 files changed
+190 -731

No files matched your search

+4 -4
View File
@@ -4,10 +4,10 @@ pub async fn handle(method: &str, _params: &Value) -> Result<Value, String> {
match method { match method {
"getVersion" => Ok(json!({ "getVersion" => Ok(json!({
"protocolVersion": "1.3", "protocolVersion": "1.3",
"product": "Obscura/0.1.0", "product": "Chrome/145.0.0.0",
"revision": "0", "revision": "@0000000000000000000000000000000000000000",
"userAgent": "Obscura/0.1.0 (Headless Browser)", "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"jsVersion": "N/A", "jsVersion": "14.5.0.0",
})), })),
"close" => { "close" => {
Ok(json!({})) Ok(json!({}))
+13 -35
View File
@@ -146,27 +146,13 @@ pub async fn start_with_full_serve_options(
let (ws_tx, mut ws_rx) = mpsc::channel::<std::net::TcpStream>(MAX_PENDING_WS_HANDOFFS); let (ws_tx, mut ws_rx) = mpsc::channel::<std::net::TcpStream>(MAX_PENDING_WS_HANDOFFS);
// Issue #225: coordinate Ctrl-C shutdown across the accept thread, the // Ctrl-C / graceful shutdown coordination.
// LocalSet accept loop, and `cdp_processor`. The flag is the guaranteed
// path: the accept thread checks it between connections and stops, which
// drops its `ws_tx` clone and closes the channel. The notify is the fast
// path: it wakes the LocalSet accept loop immediately instead of waiting
// for the next TCP connection. `cdp_processor` sets both when it catches
// Ctrl-C.
let shutdown_flag = Arc::new(AtomicBool::new(false)); let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_notify = Arc::new(Notify::new()); let shutdown_notify = Arc::new(Notify::new());
// Dedicated accept thread: drains the kernel backlog immediately and // Dedicated accept thread: drains the kernel backlog immediately and
// handles HTTP endpoints (/json/version, /json, /json/protocol) with // handles HTTP endpoints (/json/version, /json, /json/protocol) with
// blocking I/O so they never contend with the LocalSet's V8 work. // blocking I/O so they never contend with the LocalSet's V8 work.
//
// Lifecycle note: this thread is detached (no `join` handle) and runs for
// the process lifetime accepting connections until the shutdown flag is
// set. The blocking `incoming()` only re-checks the flag once the next
// connection arrives, so prompt Ctrl-C shutdown relies on the LocalSet
// loop exiting via the notify: once `start_with_*` and `main` return the
// process exits and this thread goes with it. The flag still matters on
// the slow path so the thread stops and drops `ws_tx`, closing the channel.
let accept_flag = shutdown_flag.clone(); let accept_flag = shutdown_flag.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("obscura-cdp-accept".into()) .name("obscura-cdp-accept".into())
@@ -195,18 +181,15 @@ pub async fn start_with_full_serve_options(
let _processor_handle = tokio::task::spawn_local(cdp_processor( let _processor_handle = tokio::task::spawn_local(cdp_processor(
msg_rx, proxy, stealth, user_agent, allow_file_access, storage_dir, msg_rx, proxy, stealth, user_agent, allow_file_access, storage_dir,
allow_private_network, shutdown_flag.clone(), shutdown_notify.clone(), allow_private_network,
shutdown_flag,
shutdown_notify.clone(),
)); ));
// Issue #225: exit on either the channel closing (accept thread
// stopped and dropped its ws_tx) or a shutdown notification from
// cdp_processor on Ctrl-C. Waiting only on ws_rx.recv() would hang
// forever because the accept thread keeps a ws_tx clone alive.
let accept_notify = shutdown_notify.clone();
loop { loop {
let stream = tokio::select! { let stream = tokio::select! {
stream = ws_rx.recv() => stream, stream = ws_rx.recv() => stream,
_ = accept_notify.notified() => None, _ = shutdown_notify.notified() => None,
}; };
let stream = match stream { let stream = match stream {
Some(s) => s, Some(s) => s,
@@ -319,11 +302,11 @@ fn handle_http_json_blocking(
let body = match endpoint { let body = match endpoint {
"version" => serde_json::to_string_pretty(&json!({ "version" => serde_json::to_string_pretty(&json!({
"Browser": "Obscura/0.1.0", "Browser": "Chrome/145.0.0.0",
"Protocol-Version": "1.3", "Protocol-Version": "1.3",
"User-Agent": "Obscura/0.1.0 (Headless Browser)", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"V8-Version": "N/A", "V8-Version": "14.5.0.0",
"WebKit-Version": "N/A", "WebKit-Version": "537.36",
"webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port), "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port),
}))?, }))?,
"list" => serde_json::to_string_pretty(&json!([{ "list" => serde_json::to_string_pretty(&json!([{
@@ -404,11 +387,6 @@ async fn cdp_processor(
}, },
_ = &mut shutdown => { _ = &mut shutdown => {
tracing::info!("Shutdown signal received"); tracing::info!("Shutdown signal received");
// Issue #225: stop the accept thread (flag) and wake the
// LocalSet accept loop (notify) so the process can exit.
// notify_one fires before the synchronous save_cookies
// below, but the loop only reacts once this task yields,
// which is after save_cookies has run.
shutdown_flag.store(true, Ordering::Relaxed); shutdown_flag.store(true, Ordering::Relaxed);
shutdown_notify.notify_one(); shutdown_notify.notify_one();
break; break;
@@ -879,10 +857,10 @@ fn fast_path_response(text: &str) -> Option<String> {
"Browser.getVersion" => { "Browser.getVersion" => {
Some(json!({ Some(json!({
"protocolVersion": "1.3", "protocolVersion": "1.3",
"product": "Obscura/0.1.0", "product": "Chrome/145.0.0.0",
"revision": "0", "revision": "@0000000000000000000000000000000000000000",
"userAgent": "Obscura/0.1.0", "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"jsVersion": "V8", "jsVersion": "14.5.0.0",
})) }))
} }
"Browser.setDownloadBehavior" | "Browser.getWindowBounds" => { "Browser.setDownloadBehavior" | "Browser.getWindowBounds" => {
@@ -111,7 +111,7 @@ async fn one_client(port: u16, id_base: u64) -> Result<(), String> {
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
#[ignore = "boots a real CDP server; opt-in with --ignored"]
async fn concurrency_5_does_not_abort_v8() { async fn concurrency_5_does_not_abort_v8() {
let port = pick_port().await; let port = pick_port().await;
let local = tokio::task::LocalSet::new(); let local = tokio::task::LocalSet::new();
@@ -173,7 +173,7 @@ async fn one_client_with_fetch(port: u16, id_base: u64, target_url: &str) -> Res
/// PR #36 maintainer's exact repro. /// PR #36 maintainer's exact repro.
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
#[ignore = "boots a real CDP server + makes outbound HTTP; opt-in with --ignored"]
async fn fetch_intercept_concurrency_5_does_not_abort_v8() { async fn fetch_intercept_concurrency_5_does_not_abort_v8() {
// Use example.com first — minimal subresources but real network. If this // Use example.com first — minimal subresources but real network. If this
// doesn't reproduce, escalate to a JS-heavy page in a follow-up. // doesn't reproduce, escalate to a JS-heavy page in a follow-up.
@@ -35,7 +35,7 @@ async fn pick_port() -> u16 {
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
#[ignore = "boots a real CDP server; opt-in with --ignored"]
async fn http_control_plane_unblocked_during_long_js() { async fn http_control_plane_unblocked_during_long_js() {
let port = pick_port().await; let port = pick_port().await;
let port_clone = port; let port_clone = port;
-36
View File
@@ -1,36 +0,0 @@
use std::env;
fn env_value(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_version(version: String) -> String {
version.strip_prefix('v').unwrap_or(&version).to_string()
}
fn github_tag_version() -> Option<String> {
if env_value("GITHUB_REF_TYPE").as_deref() == Some("tag") {
return env_value("GITHUB_REF_NAME").map(normalize_version);
}
env_value("GITHUB_REF")
.and_then(|value| value.strip_prefix("refs/tags/").map(str::to_owned))
.map(normalize_version)
}
fn main() {
println!("cargo:rerun-if-env-changed=OBSCURA_VERSION");
println!("cargo:rerun-if-env-changed=GITHUB_REF_TYPE");
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
println!("cargo:rerun-if-env-changed=GITHUB_REF");
let version = env_value("OBSCURA_VERSION")
.map(normalize_version)
.or_else(github_tag_version)
.unwrap_or_else(|| env::var("CARGO_PKG_VERSION").expect("Cargo sets CARGO_PKG_VERSION"));
println!("cargo:rustc-env=OBSCURA_BUILD_VERSION={version}");
}
+7 -76
View File
@@ -10,7 +10,7 @@ use tokio::time::{timeout, Duration};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "obscura", name = "obscura",
version = env!("OBSCURA_BUILD_VERSION"), version = env!("CARGO_PKG_VERSION"),
about = "Obscura - A lightweight headless browser for web scraping and automation", about = "Obscura - A lightweight headless browser for web scraping and automation",
)] )]
struct Args { struct Args {
@@ -189,9 +189,9 @@ fn print_banner(port: u16) {
| |__| | |_) \__ \ (__| |_| | | | (_| | | |__| | |_) \__ \ (__| |_| | | | (_| |
\____/|_.__/|___/\___|\__,_|_| \__,_| \____/|_.__/|___/\___|\__,_|_| \__,_|
Headless Browser v{} Headless Browser v0.1.6
CDP server: ws://127.0.0.1:{}/devtools/browser CDP server: ws://127.0.0.1:{}/devtools/browser
"#, env!("OBSCURA_BUILD_VERSION"), port); "#, port);
} }
fn select_log_filter(verbose: bool, quiet: bool) -> &'static str { fn select_log_filter(verbose: bool, quiet: bool) -> &'static str {
@@ -215,26 +215,6 @@ fn merge_proxy(global_proxy: Option<String>, command_proxy: Option<String>) -> O
command_proxy.or(global_proxy) command_proxy.or(global_proxy)
} }
/// `--stealth` routes outbound traffic through `wreq`, which doesn't speak
/// SOCKS5. A previous workaround silently rewrote `socks5://` to `http://`,
/// which broke plain SOCKS5 servers (#160). Refuse the combination at
/// startup so the user gets a clear message instead of `TunnelUnexpectedEof`.
fn reject_stealth_with_socks5(proxy: Option<&str>, stealth: bool) -> anyhow::Result<()> {
if !stealth {
return Ok(());
}
let Some(p) = proxy else { return Ok(()) };
let scheme = p.split("://").next().unwrap_or("").to_ascii_lowercase();
if scheme == "socks5" || scheme == "socks5h" {
anyhow::bail!(
"--stealth does not support SOCKS5 proxies (the stealth HTTP \
client cannot reach the upstream). Use --proxy http://... \
or drop --stealth."
);
}
Ok(())
}
/// Normalize a raw `--v8-flags` value into the string we'll hand to V8. /// Normalize a raw `--v8-flags` value into the string we'll hand to V8.
/// Returns `None` when the user didn't pass the flag, passed an empty string, /// Returns `None` when the user didn't pass the flag, passed an empty string,
/// or passed only whitespace; in those cases V8 is left untouched. /// or passed only whitespace; in those cases V8 is left untouched.
@@ -283,23 +263,11 @@ async fn main() -> anyhow::Result<()> {
tracing::debug!("V8 flags: {}", v8_flags); tracing::debug!("V8 flags: {}", v8_flags);
obscura_js::set_v8_flags(&v8_flags); obscura_js::set_v8_flags(&v8_flags);
// The js-side fetch path (op_fetch_url) reads OBSCURA_ALLOW_PRIVATE_NETWORK
// directly for its SSRF gate. Mirror the CLI flag into the env var so
// iframe loads and JS fetch() see the same policy the http_client layer
// already uses (issue #33).
if args.allow_private_network {
// SAFETY: set_var is unsafe in newer rustc; this runs before any
// spawned thread inspects the env, so it's effectively single
// threaded at this point.
unsafe { std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); }
}
let global_proxy = args.proxy.clone(); let global_proxy = args.proxy.clone();
match args.command { match args.command {
Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access, storage_dir }) => { Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access, storage_dir }) => {
let proxy = merge_proxy(global_proxy.clone(), proxy); let proxy = merge_proxy(global_proxy.clone(), proxy);
reject_stealth_with_socks5(proxy.as_deref(), stealth)?;
print_banner(port); print_banner(port);
if let Some(ref dir) = storage_dir { if let Some(ref dir) = storage_dir {
tracing::info!("Storage dir: {}", dir.display()); tracing::info!("Storage dir: {}", dir.display());
@@ -330,19 +298,17 @@ async fn main() -> anyhow::Result<()> {
} }
} }
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet, storage_dir }) => { Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet, storage_dir }) => {
reject_stealth_with_socks5(global_proxy.as_deref(), stealth)?;
run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network).await?; run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network).await?;
} }
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => { Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?; run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?;
} }
Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => { Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => {
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy.clone()); let mcp_proxy = merge_proxy(global_proxy.clone(), proxy);
reject_stealth_with_socks5(mcp_proxy.as_deref(), stealth)?;
if http { if http {
obscura_mcp::http::run(port, proxy, user_agent, stealth).await?; obscura_mcp::http::run(port, mcp_proxy, user_agent, stealth).await?;
} else { } else {
obscura_mcp::run(proxy, user_agent, stealth).await?; obscura_mcp::run(mcp_proxy, user_agent, stealth).await?;
} }
} }
None => { None => {
@@ -1114,7 +1080,7 @@ mod tests {
use super::{ use super::{
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes, effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags,
reject_stealth_with_socks5, resolve_asset_url, select_log_filter, write_or_print, resolve_asset_url, select_log_filter, write_or_print,
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS, write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS,
}; };
use clap::Parser; use clap::Parser;
@@ -1388,41 +1354,6 @@ mod tests {
assert!(merged.contains("--expose-gc")); assert!(merged.contains("--expose-gc"));
} }
#[test]
fn reject_stealth_with_socks5_passes_when_no_stealth() {
assert!(reject_stealth_with_socks5(Some("socks5://127.0.0.1:1080"), false).is_ok());
}
#[test]
fn reject_stealth_with_socks5_passes_when_no_proxy() {
assert!(reject_stealth_with_socks5(None, true).is_ok());
}
#[test]
fn reject_stealth_with_socks5_passes_for_http_proxy() {
assert!(reject_stealth_with_socks5(Some("http://127.0.0.1:8080"), true).is_ok());
assert!(reject_stealth_with_socks5(Some("https://proxy.example:443"), true).is_ok());
}
#[test]
fn reject_stealth_with_socks5_fails_for_socks5() {
let err = reject_stealth_with_socks5(Some("socks5://127.0.0.1:9999"), true).unwrap_err();
assert!(err.to_string().contains("SOCKS5"));
assert!(err.to_string().contains("--stealth"));
}
#[test]
fn reject_stealth_with_socks5_fails_for_socks5h() {
let err = reject_stealth_with_socks5(Some("socks5h://127.0.0.1:9999"), true).unwrap_err();
assert!(err.to_string().contains("SOCKS5"));
}
#[test]
fn reject_stealth_with_socks5_is_case_insensitive() {
let err = reject_stealth_with_socks5(Some("SOCKS5://127.0.0.1:9999"), true).unwrap_err();
assert!(err.to_string().contains("SOCKS5"));
}
#[test] #[test]
fn parsed_fetch_quiet_resolves_to_off_filter() { fn parsed_fetch_quiet_resolves_to_off_filter() {
let args = Args::try_parse_from([ let args = Args::try_parse_from([
-35
View File
@@ -223,14 +223,6 @@ impl DomTree {
} }
pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) { pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) {
// Per DOM spec, appending a node to itself is a HierarchyRequestError;
// here we treat it as a no-op rather than panic. Without this the
// sibling-pointer fixup below sets the node's prev_sibling to itself
// and every later child-walk loops forever (same failure mode that
// insert_before's self-cycle guard was added to prevent).
if parent_id == child_id {
return;
}
self.detach(child_id); self.detach(child_id);
let mut inner = self.inner.borrow_mut(); let mut inner = self.inner.borrow_mut();
@@ -260,15 +252,6 @@ impl DomTree {
} }
pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) { pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) {
// Per DOM spec: if the node being inserted IS the reference node,
// the operation is a no-op (the node is already in its target
// position). Without this, the linked-list fixup below sets the
// node's prev_sibling and next_sibling to itself, creating a cycle
// — every later traversal (childNodes, querySelectorAll, etc) then
// loops forever and the test page hangs while obscura burns RAM.
if existing_id == new_sibling_id {
return;
}
let (parent_id, prev_id) = { let (parent_id, prev_id) = {
let inner = self.inner.borrow(); let inner = self.inner.borrow();
let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) { let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) {
@@ -480,20 +463,6 @@ impl DomTree {
pub fn text_content(&self, node_id: NodeId) -> String { pub fn text_content(&self, node_id: NodeId) -> String {
let inner = self.inner.borrow(); let inner = self.inner.borrow();
// Per DOM spec, calling textContent ON a CharacterData node
// (Text, Comment, ProcessingInstruction) returns its .data.
// Calling textContent on an Element walks descendants and
// concatenates Text node content only (Comment + PI are
// skipped). Handle the direct-CharacterData case here so the
// descent helper can keep its element-centric behavior.
if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
match &node.data {
NodeData::Text { contents } => return contents.clone(),
NodeData::Comment { contents } => return contents.clone(),
NodeData::ProcessingInstruction { data, .. } => return data.clone(),
_ => {}
}
}
let mut result = String::new(); let mut result = String::new();
collect_text_inner(&inner, node_id, &mut result); collect_text_inner(&inner, node_id, &mut result);
result result
@@ -601,10 +570,6 @@ fn collect_text_inner(inner: &DomTreeInner, node_id: NodeId, buf: &mut String) {
if let Some(Some(node)) = inner.nodes.get(node_id.index()) { if let Some(Some(node)) = inner.nodes.get(node_id.index()) {
match &node.data { match &node.data {
NodeData::Text { contents } => buf.push_str(contents), NodeData::Text { contents } => buf.push_str(contents),
// Comment and ProcessingInstruction are intentionally NOT
// appended when traversing descendants: per spec, textContent
// on an Element only includes Text descendants. Direct
// textContent on a Comment/PI is handled by the caller.
_ => { _ => {
let mut child = node.first_child; let mut child = node.first_child;
while let Some(child_id) = child { while let Some(child_id) = child {
+145 -496
View File
@@ -9,42 +9,19 @@ globalThis.onerror = function(msg, src, line, col, error) {
globalThis.__obscura_errors.push({msg: String(msg), src: String(src||""), line, error: String(error||"")}); globalThis.__obscura_errors.push({msg: String(msg), src: String(src||""), line, error: String(error||"")});
}; };
globalThis.__windowListeners = {}; globalThis.__windowListeners = {};
globalThis.addEventListener = function(type, fn, opts) { globalThis.addEventListener = function(type, fn) {
if (typeof fn !== 'function' &&
!(fn && typeof fn === 'object' && typeof fn.handleEvent === 'function')) return;
const once = !!(opts && typeof opts === 'object' && opts.once === true);
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
if (!globalThis.__windowListeners[type]) globalThis.__windowListeners[type] = []; if (!globalThis.__windowListeners[type]) globalThis.__windowListeners[type] = [];
const arr = globalThis.__windowListeners[type]; globalThis.__windowListeners[type].push(fn);
for (const e of arr) if (e.handler === fn && e.capture === capture) return;
arr.push({ handler: fn, once, capture });
}; };
globalThis.removeEventListener = function(type, fn, opts) { globalThis.removeEventListener = function(type, fn) {
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
if (globalThis.__windowListeners[type]) { if (globalThis.__windowListeners[type]) {
globalThis.__windowListeners[type] = globalThis.__windowListeners[type] globalThis.__windowListeners[type] = globalThis.__windowListeners[type].filter(h => h !== fn);
.filter(e => !(e.handler === fn && e.capture === capture));
} }
}; };
globalThis.dispatchEvent = function(event) { globalThis.dispatchEvent = function(event) {
if (!event) return true; if (!event) return true;
if (!event.target) event.target = globalThis; const handlers = globalThis.__windowListeners[event.type] || [];
event.currentTarget = globalThis; for (const h of handlers) { try { h.call(globalThis, event); } catch(e) { console.error(e); } }
const slot = globalThis.__windowListeners[event.type] || [];
const handlers = slot.slice();
for (const entry of handlers) {
const h = entry && entry.handler;
if (!h) continue;
try {
if (typeof h === 'function') h.call(globalThis, event);
else if (typeof h.handleEvent === 'function') h.handleEvent(event);
} catch (e) { console.error(e); }
if (entry.once) {
const i = slot.indexOf(entry);
if (i !== -1) slot.splice(i, 1);
}
if (event && event._immediatePropagationStopped) break;
}
return !event.defaultPrevented; return !event.defaultPrevented;
}; };
@@ -258,14 +235,7 @@ class Node {
get nodeType() { return +_dom("node_type", this._nid); } get nodeType() { return +_dom("node_type", this._nid); }
get nodeName() { return _domParse("node_name", this._nid) || ""; } get nodeName() { return _domParse("node_name", this._nid) || ""; }
get ownerDocument() { return globalThis.document; } get ownerDocument() { return globalThis.document; }
get textContent() { get textContent() { return _domParse("text_content", this._nid) ?? ""; }
// Per DOM spec: textContent returns null for Document and DocumentType.
// Everything else returns the concatenated text of descendants
// (or the node's data for Text/Comment/PI/Attr).
const t = this.nodeType;
if (t === 9 || t === 10) return null;
return _domParse("text_content", this._nid) ?? "";
}
set textContent(v) { set textContent(v) {
const oldChildren = _domParse("child_nodes", this._nid) || []; const oldChildren = _domParse("child_nodes", this._nid) || [];
for (const c of oldChildren) _dom("remove_child", c); for (const c of oldChildren) _dom("remove_child", c);
@@ -304,29 +274,7 @@ class Node {
get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); } get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); }
get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); } get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); }
appendChild(c) { appendChild(c) {
if (!c || typeof c._nid !== 'number') return c; if (!c) return c;
if (c._nid === this._nid) return c;
// DocumentFragment per spec: move its children up to `this` and leave
// the fragment empty. Pages that build subtrees off-DOM (Bing's
// anti-malware shim, Google Shopping result rows, React/Preact
// hydration) appendChild a fragment and then read its former
// children's properties; without flattening, the fragment node
// itself gets grafted and the next `.something.length` access on
// the assumed-Element children explodes with
// "Cannot read properties of undefined (reading 'length')".
if (c.nodeType === 11) {
const kids = (c.childNodes || []).slice();
const added = [];
for (const k of kids) {
if (typeof k._nid !== 'number') continue;
_dom("append_child", this._nid, k._nid);
added.push(k._nid);
}
if (globalThis.__mutationObservers?.length && added.length) {
globalThis.__notifyMutation('childList', this._nid, added, []);
}
return c;
}
_dom("append_child", this._nid, c._nid); _dom("append_child", this._nid, c._nid);
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
if (c instanceof Element && c.tagName === 'SCRIPT') { if (c instanceof Element && c.tagName === 'SCRIPT') {
@@ -390,40 +338,14 @@ class Node {
return c; return c;
} }
replaceChild(newChild, oldChild) { replaceChild(newChild, oldChild) {
if (!oldChild || !newChild || typeof oldChild._nid !== 'number') return oldChild; if (!oldChild || !newChild) return oldChild;
if (newChild.nodeType === 11) {
// Flatten fragment: insert each child before oldChild, then remove oldChild.
// op_dom("insert_before", newNode, refNode) -> insert newNode before refNode.
const kids = (newChild.childNodes || []).slice();
for (const k of kids) {
if (typeof k._nid !== 'number') continue;
if (k._nid === oldChild._nid) continue;
_dom("insert_before", k._nid, oldChild._nid);
}
_dom("remove_child", oldChild._nid);
return oldChild;
}
if (typeof newChild._nid !== 'number') return oldChild;
if (newChild._nid === oldChild._nid) return oldChild;
_dom("insert_before", newChild._nid, oldChild._nid); _dom("insert_before", newChild._nid, oldChild._nid);
_dom("remove_child", oldChild._nid); _dom("remove_child", oldChild._nid);
return oldChild; return oldChild;
} }
insertBefore(n, ref) { insertBefore(n, ref) {
if (!n || typeof n._nid !== 'number') return n; if (!n) return n;
if (!ref) { this.appendChild(n); return n; } if (!ref) { this.appendChild(n); return n; }
if (typeof ref._nid !== 'number') return n;
// DocumentFragment: flatten — insert each child of the fragment
// before ref (in order), then leave the fragment empty.
if (n.nodeType === 11) {
const kids = (n.childNodes || []).slice();
for (const k of kids) {
if (typeof k._nid !== 'number') continue;
if (k._nid === ref._nid) continue;
_dom("insert_before", k._nid, ref._nid);
}
return n;
}
_dom("insert_before", n._nid, ref._nid); _dom("insert_before", n._nid, ref._nid);
return n; return n;
} }
@@ -546,13 +468,6 @@ class Comment extends CharacterData {
cloneNode() { return document.createComment(this.data); } cloneNode() { return document.createComment(this.data); }
} }
class ProcessingInstruction extends CharacterData {
get nodeName() { return this.target || ""; }
get nodeType() { return 7; }
get target() { return this._target || _domParse("pi_target", this._nid) || ""; }
cloneNode() { return document.createProcessingInstruction(this.target, this.data); }
}
class Element extends Node { class Element extends Node {
constructor(nid) { constructor(nid) {
super(nid); super(nid);
@@ -565,11 +480,8 @@ class Element extends Node {
get className() { return this.getAttribute("class") || ""; } get className() { return this.getAttribute("class") || ""; }
set className(v) { this.setAttribute("class", v); } set className(v) { this.setAttribute("class", v); }
get namespaceURI() { get namespaceURI() {
if (this._ns !== undefined) {
return this._ns === "" ? null : this._ns;
}
const tag = this.localName; const tag = this.localName;
if (tag === "svg") return "http://www.w3.org/2000/svg"; if (tag === "svg" || this._ns === "http://www.w3.org/2000/svg") return "http://www.w3.org/2000/svg";
return "http://www.w3.org/1999/xhtml"; return "http://www.w3.org/1999/xhtml";
} }
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
@@ -722,61 +634,16 @@ class Element extends Node {
break; break;
} }
} }
// testharness.js's output renderer calls insertAdjacentText on its
// results table. Without this, the renderer throws inside a forEach
// that has no try/catch and the chain of completion callbacks dies
// before reaching our runner hook, so EVERY WPT test with subtests
// looked like a timeout. Adding this method unblocks the harness
// pipeline and surfaces real subtest pass/fail.
insertAdjacentText(position, text) {
const node = document.createTextNode(String(text));
const parent = this.parentNode;
switch (position) {
case 'beforebegin': if (parent) parent.insertBefore(node, this); break;
case 'afterbegin': this.insertBefore(node, this.firstChild); break;
case 'beforeend': this.appendChild(node); break;
case 'afterend': if (parent) parent.insertBefore(node, this.nextSibling); break;
}
}
insertAdjacentElement(position, element) {
if (!element || element.nodeType !== 1) return null;
const parent = this.parentNode;
switch (position) {
case 'beforebegin': if (parent) { parent.insertBefore(element, this); return element; } return null;
case 'afterbegin': this.insertBefore(element, this.firstChild); return element;
case 'beforeend': this.appendChild(element); return element;
case 'afterend': if (parent) { parent.insertBefore(element, this.nextSibling); return element; } return null;
}
return null;
}
addEventListener(type, handler, opts) { addEventListener(type, handler, opts) {
// Per DOM spec, a listener can be a function OR an object with a
// `handleEvent` method. Anything else is silently ignored. Without
// this filter, passing null/string/number used to push a non-callable
// into the handler array and explode at dispatch time.
if (typeof handler !== 'function' &&
!(handler && typeof handler === 'object' && typeof handler.handleEvent === 'function')) {
return;
}
const once = opts && typeof opts === 'object' && opts.once === true;
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
const passive = !!(opts && typeof opts === 'object' && opts.passive);
const key = this._nid; const key = this._nid;
if (!_eventRegistry[key]) _eventRegistry[key] = {}; if (!_eventRegistry[key]) _eventRegistry[key] = {};
if (!_eventRegistry[key][type]) _eventRegistry[key][type] = []; if (!_eventRegistry[key][type]) _eventRegistry[key][type] = [];
// Dedupe: spec says same (type, callback, capture) is a no-op. _eventRegistry[key][type].push(handler);
const arr = _eventRegistry[key][type];
for (const e of arr) {
if (e.handler === handler && e.capture === capture) return;
}
arr.push({ handler, once, capture, passive });
} }
removeEventListener(type, handler, opts) { removeEventListener(type, handler) {
const key = this._nid; const key = this._nid;
const capture = (typeof opts === 'boolean') ? opts : !!(opts && opts.capture);
if (_eventRegistry[key] && _eventRegistry[key][type]) { if (_eventRegistry[key] && _eventRegistry[key][type]) {
_eventRegistry[key][type] = _eventRegistry[key][type] _eventRegistry[key][type] = _eventRegistry[key][type].filter(h => h !== handler);
.filter(e => !(e.handler === handler && e.capture === capture));
} }
} }
dispatchEvent(event) { dispatchEvent(event) {
@@ -797,25 +664,9 @@ class Element extends Node {
if (ret === false) event.preventDefault(); if (ret === false) event.preventDefault();
} catch(e) { console.error(e); } } catch(e) { console.error(e); }
} }
// Snapshot the listener list — `once: true` listeners are removed const handlers = (_eventRegistry[this._nid] || {})[event.type] || [];
// during dispatch, but spec says dispatched listeners are captured for (const h of handlers) {
// before the loop starts. try { h.call(this, event); } catch(e) { console.error(e); }
const slot = (_eventRegistry[this._nid] || {})[event.type] || [];
const handlers = slot.slice();
for (const entry of handlers) {
const h = entry && entry.handler;
if (!h) continue;
try {
if (typeof h === 'function') {
h.call(this, event);
} else if (typeof h.handleEvent === 'function') {
h.handleEvent(event);
}
} catch (e) { console.error(e); }
if (entry.once) {
const i = slot.indexOf(entry);
if (i !== -1) slot.splice(i, 1);
}
if (event._immediatePropagationStopped) break; if (event._immediatePropagationStopped) break;
} }
if (event.bubbles && !event._propagationStopped && this.parentNode) { if (event.bubbles && !event._propagationStopped && this.parentNode) {
@@ -920,49 +771,8 @@ class Element extends Node {
set name(v) { this.setAttribute("name", v); } set name(v) { this.setAttribute("name", v); }
get placeholder() { return this.getAttribute("placeholder") || ""; } get placeholder() { return this.getAttribute("placeholder") || ""; }
set placeholder(v) { this.setAttribute("placeholder", v); } set placeholder(v) { this.setAttribute("placeholder", v); }
get href() { get href() { return this.getAttribute("href") || ""; }
const raw = this.getAttribute("href");
if (raw == null) return "";
// Per spec, HTMLAnchorElement.href / HTMLAreaElement.href returns the
// serialized resolved URL (the content attribute resolved against the
// document base). Bing's anti-malware shim and many other libraries
// rely on this resolution to read `.hostname` / `.protocol`. We only
// resolve for <a>/<area>/<base>/<link> so other elements that happen
// to carry an `href` attribute (custom elements, SVG <a>, etc) keep
// the raw value they were assigned.
const tag = this.localName;
if (tag === 'a' || tag === 'area' || tag === 'base' || tag === 'link') {
try {
return new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank").href;
} catch { return raw; }
}
return raw;
}
set href(v) { this.setAttribute("href", v); } set href(v) { this.setAttribute("href", v); }
// URL-decomposition IDL attributes for <a> / <area>. Without these,
// libraries that build an anchor and read parts of the resolved URL
// (Bing's CI.AntiMalware shim does `n=createElement("A"); n.href=src;
// n.hostname.length`) crash with "Cannot read properties of undefined".
_urlPart(part) {
const tag = this.localName;
if (tag !== 'a' && tag !== 'area') return "";
const raw = this.getAttribute("href");
if (raw == null || raw === "") return "";
try {
const u = new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank");
return u[part] || "";
} catch { return ""; }
}
get origin() { return this._urlPart('origin'); }
get protocol() { return this._urlPart('protocol'); }
get username() { return this._urlPart('username'); }
get password() { return this._urlPart('password'); }
get host() { return this._urlPart('host'); }
get hostname() { return this._urlPart('hostname'); }
get port() { return this._urlPart('port'); }
get pathname() { return this._urlPart('pathname'); }
get search() { return this._urlPart('search'); }
get hash() { return this._urlPart('hash'); }
get src() { return this.getAttribute("src") || ""; } get src() { return this.getAttribute("src") || ""; }
set src(v) { set src(v) {
this.setAttribute("src", v); this.setAttribute("src", v);
@@ -986,7 +796,6 @@ class Element extends Node {
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
} }
_registerIframe(el); _registerIframe(el);
_activateIframe(el);
if (typeof el.onload === 'function') { if (typeof el.onload === 'function') {
try { el.onload(); } catch(e) {} try { el.onload(); } catch(e) {}
} else { } else {
@@ -1282,24 +1091,6 @@ class Document extends Node {
_cache.set(nid, n); _cache.set(nid, n);
return n; return n;
} }
createProcessingInstruction(target, data) {
// Spec: target must be a valid XML Name, data must not contain "?>".
// Throw InvalidCharacterError on either violation. Simplified XML
// Name check (first char + remainder is enough for the WPT tests).
const t = String(target);
const d = String(data ?? "");
if (!t.length || !/^[A-Za-z_:][\w.\-:]*$/.test(t)) {
const e = new Error("InvalidCharacterError"); e.name = "InvalidCharacterError"; throw e;
}
if (d.indexOf("?>") !== -1) {
const e = new Error("InvalidCharacterError"); e.name = "InvalidCharacterError"; throw e;
}
const nid = +_dom("create_processing_instruction", t, d);
const n = new ProcessingInstruction(nid);
n._target = t;
_cache.set(nid, n);
return n;
}
createDocumentFragment() { createDocumentFragment() {
const nid = +_dom("create_document_fragment"); const nid = +_dom("create_document_fragment");
const frag = new DocumentFragment(nid); const frag = new DocumentFragment(nid);
@@ -1469,19 +1260,6 @@ class Document extends Node {
const html = `<${safe}></${safe}>`; const html = `<${safe}></${safe}>`;
return new DOMParser().parseFromString(html, "application/xml"); return new DOMParser().parseFromString(html, "application/xml");
}, },
// DOM Level 2: a DocumentType node usable as the third arg of
// createDocument(). We allocate a Doctype node in the DOM tree and
// wrap it. The new node is detached (no parent) which matches the
// spec; callers like createDocument(ns, name, doctype) attach it.
createDocumentType(name, publicId, systemId) {
const nid = +_dom("create_doctype", String(name || ""), String(publicId || ""));
// ops.rs::create_doctype takes (name, publicId) — systemId stored
// here only in the JS wrapper since neither current test consumes
// it from the underlying tree.
const dt = new DocumentType(nid, String(name || ""), String(publicId || ""), String(systemId || ""));
_cache.set(nid, dt);
return dt;
},
hasFeature() { return true; }, hasFeature() { return true; },
}; };
} }
@@ -1578,16 +1356,8 @@ function _wrap(nid) {
let n; let n;
if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); } if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); }
else if (t === 3) n = new Text(nid); else if (t === 3) n = new Text(nid);
else if (t === 7) n = new ProcessingInstruction(nid);
else if (t === 8) n = new Comment(nid); else if (t === 8) n = new Comment(nid);
else if (t === 9) n = new Document(nid); else if (t === 9) n = new Document(nid);
else if (t === 10) {
// DocumentType wrapper. Pull the name/publicId from the tree so
// the wrapper exposes the spec'd .name / .publicId getters.
const name = _domParse("doctype_name", nid) || "";
const publicId = _domParse("doctype_public_id", nid) || "";
n = new DocumentType(nid, name, publicId, "");
}
else n = new Node(nid); else n = new Node(nid);
_cache.set(nid, n); _cache.set(nid, n);
return n; return n;
@@ -1683,9 +1453,6 @@ Object.defineProperty(globalThis.Window, Symbol.hasInstance, {
const _iframeRegistry = []; const _iframeRegistry = [];
function _registerIframe(iframeEl) { function _registerIframe(iframeEl) {
// Same iframe element can have its src reassigned; we don't want to
// double-register and fan out events twice to the same window.
if (_iframeRegistry.indexOf(iframeEl) !== -1) return;
const idx = _iframeRegistry.length; const idx = _iframeRegistry.length;
_iframeRegistry.push(iframeEl); _iframeRegistry.push(iframeEl);
globalThis.length = _iframeRegistry.length; globalThis.length = _iframeRegistry.length;
@@ -1695,29 +1462,6 @@ function _registerIframe(iframeEl) {
enumerable: false, enumerable: false,
}); });
} }
// Run inline <script> blocks from the iframe HTML in the parent realm
// (we don't have per-frame realms) and rewire <body onfoo="..."> handler
// attributes as window-level listeners on the iframe window.
function _activateIframe(iframeEl) {
const doc = iframeEl._iframeDoc;
const win = iframeEl._iframeWin;
if (!doc || !win) return;
if (Array.isArray(doc._inlineScripts)) {
for (const src of doc._inlineScripts) {
try { (0, eval)(src); } catch (e) {}
}
}
if (doc._bodyHandlers) {
for (const onName of Object.keys(doc._bodyHandlers)) {
const type = onName.slice(2); // "onstorage" -> "storage"
const code = doc._bodyHandlers[onName];
const handler = function(event) {
try { (new Function('event', code)).call(win, event); } catch (e) {}
};
try { win.addEventListener(type, handler); } catch (e) {}
}
}
}
globalThis.navigator = { globalThis.navigator = {
get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; }, get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; },
get appVersion() { return this.userAgent.replace('Mozilla/', ''); }, get appVersion() { return this.userAgent.replace('Mozilla/', ''); },
@@ -1741,6 +1485,7 @@ globalThis.navigator = {
p.item = (i) => p[i] || null; p.item = (i) => p[i] || null;
p.namedItem = (name) => p.find(x => x.name === name) || null; p.namedItem = (name) => p.find(x => x.name === name) || null;
p.refresh = () => {}; p.refresh = () => {};
p[Symbol.iterator] = function() { return this[Symbol.iterator](); };
return p; return p;
}, },
get mimeTypes() { get mimeTypes() {
@@ -1798,18 +1543,75 @@ globalThis.navigator = {
getGamepads() { return []; }, getGamepads() { return []; },
sendBeacon() { return true; }, sendBeacon() { return true; },
javaEnabled() { return false; }, javaEnabled() { return false; },
geolocation: {
getCurrentPosition(success, error) {
const coords = {
latitude: 50.1109 + (_fpRand(500) - 0.5) * 0.1,
longitude: 8.6821 + (_fpRand(501) - 0.5) * 0.1,
accuracy: 10 + _fpRand(502) * 40,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
};
const pos = { coords, timestamp: Date.now() };
if (typeof success === 'function') success(pos);
},
watchPosition(success, error) {
if (typeof success === 'function') {
const coords = {
latitude: 50.1109 + (_fpRand(503) - 0.5) * 0.1,
longitude: 8.6821 + (_fpRand(504) - 0.5) * 0.1,
accuracy: 10 + _fpRand(505) * 40,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
};
success({ coords, timestamp: Date.now() });
}
return 0;
},
clearWatch() {},
},
storage: {
estimate() { return Promise.resolve({ quota: 5000000000, usage: Math.floor(_fpRand(640) * 100000000) }); },
persist() { return Promise.resolve(false); },
persisted() { return Promise.resolve(false); },
},
}; };
globalThis.chrome = { globalThis.chrome = {
app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } }, app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } },
runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} }, runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} },
csi() { return {}; }, csi() {
loadTimes() { return {}; }, const t = Date.now();
return { onloadT: t, startE: t - Math.floor(100 + _fpRand(610) * 200), pageT: 0, tran: 5, flashVersion: "" };
},
loadTimes() {
const t = Date.now() / 1000;
const request = t - 0.5 - _fpRand(611) * 0.5;
const startLoad = request + 0.05 + _fpRand(612) * 0.02;
const commit = request + 0.3 + _fpRand(613) * 0.4;
const finishDoc = commit + 0.1 + _fpRand(614) * 0.2;
const finish = finishDoc + 0.05 + _fpRand(615) * 0.1;
const firstPaint = commit + 0.03 + _fpRand(616) * 0.1;
const navTypes = ["BackForward","Reload","Link","Other"];
return {
requestTime: request, startLoadTime: startLoad * 1000, commitLoadTime: commit * 1000,
finishDocumentLoadTime: finishDoc * 1000, finishLoadTime: finish * 1000,
firstPaintTime: firstPaint * 1000, firstPaintAfterLoadTime: 0,
navigationType: navTypes[Math.floor(_fpRand(617) * 4)],
wasFetchedViaSpdy: false, wasNpnNegotiated: false,
npnNegotiatedProtocol: "http/1.1",
wasAlternateProtocolAvailable: false, connectionInfo: "http/1.1",
};
},
}; };
globalThis.Notification = class Notification { globalThis.Notification = class Notification {
static permission = "default"; static permission = "default";
static requestPermission() { return Promise.resolve("default"); } static requestPermission() { return Promise.resolve(Notification.permission); }
constructor() {} constructor() {}
}; };
@@ -1819,8 +1621,8 @@ globalThis.WebGL2RenderingContext = class WebGL2RenderingContext {};
globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} }; globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = 1; globalThis.devicePixelRatio = 1;
globalThis.innerWidth = 1280; globalThis.innerHeight = 720; globalThis.innerWidth = 1920; globalThis.innerHeight = 1000;
globalThis.outerWidth = 1280; globalThis.outerHeight = 720; globalThis.outerWidth = 1920; globalThis.outerHeight = 1080;
globalThis.scrollX = 0; globalThis.scrollY = 0; globalThis.scrollX = 0; globalThis.scrollY = 0;
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
@@ -2811,104 +2613,8 @@ globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o)
globalThis.ClipboardEvent = class extends Event {}; globalThis.ClipboardEvent = class extends Event {};
globalThis.SubmitEvent = class extends Event {}; globalThis.SubmitEvent = class extends Event {};
// AbortSignal is an EventTarget that crawlers wire up to cancel globalThis.AbortController = class AbortController { constructor(){this.signal={aborted:false,addEventListener(){},removeEventListener(){},onabort:null};} abort(){this.signal.aborted=true;} };
// long-running fetches and worker jobs. The previous shim was a plain globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){},removeEventListener(){}}; } };
// object with stub addEventListener/removeEventListener, so listeners
// for 'abort' silently never fired and any AbortSignal.timeout/.any
// chains broke. We can't extend EventTarget here because the snapshot
// build evaluates bootstrap.js top-to-bottom and EventTarget is
// assigned later (and as Node, which AbortSignal is not). Inline the
// event-target surface instead.
globalThis.AbortSignal = class AbortSignal {
constructor() {
this._aborted = false;
this._reason = undefined;
this._listeners = { abort: [] };
this.onabort = null;
}
get aborted() { return this._aborted; }
get reason() {
if (!this._aborted) return undefined;
return this._reason !== undefined
? this._reason
: new DOMException('signal is aborted without reason', 'AbortError');
}
throwIfAborted() {
if (this._aborted) throw this.reason;
}
addEventListener(type, fn) {
if (typeof fn !== 'function') return;
(this._listeners[type] = this._listeners[type] || []).push(fn);
}
removeEventListener(type, fn) {
const arr = this._listeners[type];
if (!arr) return;
const i = arr.indexOf(fn);
if (i !== -1) arr.splice(i, 1);
}
dispatchEvent(ev) {
const arr = this._listeners[ev && ev.type] || [];
for (const h of arr.slice()) {
try { h.call(this, ev); } catch (e) {}
}
return true;
}
_fireAbort() {
const ev = (typeof Event === 'function') ? new Event('abort') : { type: 'abort', target: this };
try { ev.target = this; } catch (e) {}
try { if (typeof this.onabort === 'function') this.onabort.call(this, ev); } catch (e) {}
this.dispatchEvent(ev);
}
static abort(reason) {
const s = new AbortSignal();
s._aborted = true;
s._reason = reason;
return s;
}
static timeout(ms) {
const s = new AbortSignal();
setTimeout(() => {
if (s._aborted) return;
s._aborted = true;
s._reason = new DOMException('signal timed out', 'TimeoutError');
s._fireAbort();
}, Number(ms) || 0);
return s;
}
static any(signals) {
const s = new AbortSignal();
const list = Array.from(signals || []);
for (const child of list) {
if (!(child instanceof AbortSignal)) continue;
if (child.aborted) {
s._aborted = true;
s._reason = child.reason;
return s;
}
}
const onChildAbort = function(ev) {
if (s._aborted) return;
const src = ev && ev.target;
s._aborted = true;
s._reason = src && src.reason;
s._fireAbort();
};
for (const child of list) {
if (!(child instanceof AbortSignal)) continue;
try { child.addEventListener('abort', onChildAbort); } catch (e) {}
}
return s;
}
};
globalThis.AbortController = class AbortController {
constructor() { this.signal = new AbortSignal(); }
abort(reason) {
if (this.signal._aborted) return;
this.signal._aborted = true;
this.signal._reason = reason;
this.signal._fireAbort();
}
};
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} }; if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} }; if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} }; if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
@@ -3051,22 +2757,50 @@ globalThis.performance = globalThis.performance || {
}, },
}; };
var _commonFonts = [
'Arial', 'Arial Black', 'Arial Narrow',
'Baskerville', 'Book Antiqua',
'Calibri', 'Cambria', 'Candara', 'Consolas', 'Courier New',
'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif',
'Futura',
'Garamond', 'Georgia', 'Gill Sans',
'Helvetica',
'Impact',
'Liberation Sans', 'Liberation Sans Mono', 'Liberation Serif',
'Lucida Console', 'Lucida Handwriting',
'Microsoft Sans Serif', 'Monaco',
'Noto Sans', 'Noto Serif',
'Palatino Linotype',
'Segoe UI',
'Tahoma', 'Times New Roman', 'Trebuchet MS',
'Verdana',
'Webdings', 'Wingdings',
];
Object.defineProperty(Document.prototype, 'fonts', { Object.defineProperty(Document.prototype, 'fonts', {
get() { get() {
return { const _set = _commonFonts.map((name, i) => ({
ready: Promise.resolve(), family: name, style: 'normal', weight: '400', stretch: 'normal',
check() { return true; }, status: 'loaded', loaded: Promise.resolve(this),
load() { return Promise.resolve([]); }, [Symbol.toStringTag]: 'FontFace',
add() {}, }));
delete() { return false; }, _set.forEach = (fn) => { _set.forEach(fn); };
clear() {}, _set.has = (f) => typeof f === 'string'
has() { return false; }, ? _commonFonts.some(n => n.toLowerCase() === f.toLowerCase())
forEach() {}, : _set.some(ff => ff.family === f?.family);
get size() { return 0; }, _set.delete = (f) => false;
get status() { return 'loaded'; }, _set.clear = () => {};
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; }, _set.add = () => {};
[Symbol.iterator]() { return [][Symbol.iterator](); }, _set.load = () => Promise.resolve(_set);
_set.check = (font) => {
const m = typeof font === 'string' ? font.match(/["']([^"']+)["']/) : null;
return m ? _commonFonts.some(n => n.toLowerCase() === m[1].toLowerCase()) : true;
}; };
_set.ready = Promise.resolve(_set);
_set.status = 'loaded';
_set.addEventListener = () => {};
_set.removeEventListener = () => {};
_set.dispatchEvent = () => true;
return _set;
}, },
configurable: true, configurable: true,
}); });
@@ -3075,78 +2809,10 @@ globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JS
globalThis.reportError = globalThis.reportError || ((e) => console.error(e)); globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
globalThis.Storage = function Storage() {}; globalThis.Storage = function Storage() {};
// StorageEvent: dispatched on window when localStorage / sessionStorage Storage.prototype.getItem = function(k) { return (this._data && this._data[k]) ?? null; };
// mutates. Per spec, the same window does NOT receive the event for its Storage.prototype.setItem = function(k, v) { if (this._data) this._data[k] = String(v); };
// own writes, but for an in-process single-page shim we dispatch Storage.prototype.removeItem = function(k) { if (this._data) delete this._data[k]; };
// regardless so test code that registers a listener and writes from the Storage.prototype.clear = function() { if (this._data) for (var k in this._data) delete this._data[k]; };
// same window can observe the event (which is what every webstorage WPT
// test does).
globalThis.StorageEvent = class StorageEvent extends Event {
constructor(type, init) {
init = init || {};
super(type, init);
this.key = init.key !== undefined ? init.key : null;
this.oldValue = init.oldValue !== undefined ? init.oldValue : null;
this.newValue = init.newValue !== undefined ? init.newValue : null;
this.url = init.url !== undefined ? init.url : (globalThis.location ? globalThis.location.href : '');
this.storageArea = init.storageArea !== undefined ? init.storageArea : null;
}
};
function _dispatchStorageEvent(area, key, oldValue, newValue) {
try {
const mkEvent = () => new StorageEvent('storage', {
key: key,
oldValue: oldValue == null ? null : String(oldValue),
newValue: newValue == null ? null : String(newValue),
url: globalThis.location ? globalThis.location.href : '',
storageArea: area,
bubbles: false,
cancelable: false,
});
Promise.resolve().then(() => {
try { globalThis.dispatchEvent(mkEvent()); } catch (e) {}
// Per spec the originating window does NOT receive the event, but
// other same-origin browsing contexts do. We fire on the parent
// and every iframe window so cross-frame storage listeners (used
// by webstorage WPT tests) see the change.
try {
const reg = (typeof _iframeRegistry !== 'undefined') ? _iframeRegistry : [];
for (const el of reg) {
const win = el && el._iframeWin;
if (!win) continue;
try { win.dispatchEvent(mkEvent()); } catch (e) {}
}
} catch (e) {}
});
} catch (e) {}
}
Storage.prototype.getItem = function(k) {
const v = this._data ? this._data[String(k)] : undefined;
return v == null ? null : String(v);
};
Storage.prototype.setItem = function(k, v) {
if (!this._data) return;
const key = String(k);
const newValue = String(v);
const oldValue = Object.prototype.hasOwnProperty.call(this._data, key) ? this._data[key] : null;
if (oldValue === newValue) return;
this._data[key] = newValue;
_dispatchStorageEvent(this, key, oldValue, newValue);
};
Storage.prototype.removeItem = function(k) {
if (!this._data) return;
const key = String(k);
if (!Object.prototype.hasOwnProperty.call(this._data, key)) return;
const oldValue = this._data[key];
delete this._data[key];
_dispatchStorageEvent(this, key, oldValue, null);
};
Storage.prototype.clear = function() {
if (!this._data) return;
const hadAny = Object.keys(this._data).length > 0;
for (var k in this._data) delete this._data[k];
if (hadAny) _dispatchStorageEvent(this, null, null, null);
};
Object.defineProperty(Storage.prototype, 'length', { get: function() { return this._data ? Object.keys(this._data).length : 0; } }); Object.defineProperty(Storage.prototype, 'length', { get: function() { return this._data ? Object.keys(this._data).length : 0; } });
Storage.prototype.key = function(i) { return this._data ? Object.keys(this._data)[i] ?? null : null; }; Storage.prototype.key = function(i) { return this._data ? Object.keys(this._data)[i] ?? null : null; };
@@ -3299,8 +2965,11 @@ globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNode
[ [
navigator.getBattery, navigator.getGamepads, navigator.sendBeacon, navigator.getBattery, navigator.getGamepads, navigator.sendBeacon,
navigator.javaEnabled, navigator.serviceWorker?.register, navigator.javaEnabled, navigator.geolocation?.getCurrentPosition,
navigator.geolocation?.watchPosition,
navigator.serviceWorker?.register,
navigator.permissions?.query, navigator.credentials?.get, navigator.permissions?.query, navigator.credentials?.get,
navigator.storage?.estimate, navigator.storage?.persist, navigator.storage?.persisted,
globalThis.fetch, globalThis.matchMedia, globalThis.getComputedStyle, globalThis.fetch, globalThis.matchMedia, globalThis.getComputedStyle,
globalThis.getSelection, globalThis.requestAnimationFrame, globalThis.getSelection, globalThis.requestAnimationFrame,
globalThis.cancelAnimationFrame, globalThis.setTimeout, globalThis.clearTimeout, globalThis.cancelAnimationFrame, globalThis.setTimeout, globalThis.clearTimeout,
@@ -3361,42 +3030,11 @@ class _IframeDocument {
this._body = document.createElement('body'); this._body = document.createElement('body');
this._root.appendChild(this._head); this._root.appendChild(this._head);
this._root.appendChild(this._body); this._root.appendChild(this._body);
// Capture inline scripts from the iframe HTML head so we can run
// them in the current context after wiring is complete. Real iframes
// would get their own JS realm; we share globalThis so any function
// declarations become globally visible. Tests like webstorage that
// load a helper iframe with `<script>function handler(e){...}</script>`
// in head rely on this side effect.
this._inlineScripts = [];
const scriptRe = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
let mm;
const headMatch = (html || "").match(/<head[^>]*>([\s\S]*?)<\/head>/i);
if (headMatch) {
while ((mm = scriptRe.exec(headMatch[1])) !== null) {
if (mm[1]) this._inlineScripts.push(mm[1]);
}
}
// Pull inline event-handler attributes off the <body> tag so we can
// re-attach them as window listeners. The bodyContent regex below
// strips the <body> wrapper.
this._bodyHandlers = {};
const bodyOpen = html.match(/<body\b([^>]*)>/i);
if (bodyOpen) {
const attrRe = /\s(on[a-z]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
let am;
while ((am = attrRe.exec(bodyOpen[1])) !== null) {
this._bodyHandlers[am[1].toLowerCase()] = am[2] || am[3] || am[4] || '';
}
}
var bodyContent = html var bodyContent = html
.replace(/^<!DOCTYPE[^>]*>/i, '') .replace(/^<!DOCTYPE[^>]*>/i, '')
.replace(/<\/?html[^>]*>/gi, '') .replace(/<\/?html[^>]*>/gi, '')
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '') .replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<\/?body[^>]*>/gi, '') .replace(/<\/?body[^>]*>/gi, '')
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/^\s+/, ''); // trim leading whitespace (before <body> content) .replace(/^\s+/, ''); // trim leading whitespace (before <body> content)
if (bodyContent) { if (bodyContent) {
this._body.innerHTML = bodyContent; this._body.innerHTML = bodyContent;
@@ -4057,7 +3695,7 @@ _markNative(MediaStream); _markNative(MediaStreamTrack);
_markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate); _markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate);
const _OrigDateTimeFormat = Intl.DateTimeFormat; const _OrigDateTimeFormat = Intl.DateTimeFormat;
const _defaultTZ = 'America/New_York'; const _defaultTZ = 'Europe/Germany';
Intl.DateTimeFormat = function(locales, options) { Intl.DateTimeFormat = function(locales, options) {
if (!options) options = {}; if (!options) options = {};
if (!options.timeZone) options.timeZone = _defaultTZ; if (!options.timeZone) options.timeZone = _defaultTZ;
@@ -4587,9 +4225,20 @@ globalThis.__obscura_init = function() {
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80; globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80;
globalThis.outerWidth = sw; globalThis.outerHeight = sh; globalThis.outerWidth = sw; globalThis.outerHeight = sh;
const t0 = Date.now(); var hwValues = [2, 4, 6, 8, 12, 16];
globalThis.navigator.hardwareConcurrency = hwValues[Math.floor(_fpRand(400) * hwValues.length)];
var memValues = [0.25, 0.5, 1, 2, 4, 8];
globalThis.navigator.deviceMemory = memValues[Math.floor(_fpRand(401) * memValues.length)];
const t0 = Date.now() + Math.floor(_fpRand(641) * 100) - 50;
globalThis.performance.timeOrigin = t0; globalThis.performance.timeOrigin = t0;
globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 }; globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
globalThis.performance.memory = {
jsHeapSizeLimit: 2172649472,
totalJSHeapSize: 15000000 + Math.floor(_fpRand(620) * 85000000),
usedJSHeapSize: 8000000 + Math.floor(_fpRand(621) * 42000000),
};
globalThis.Notification.permission = _fpRand(630) > 0.5 ? "granted" : "default";
// Hide internals (_*, obscura, Obscura). The set of keys is static at // Hide internals (_*, obscura, Obscura). The set of keys is static at
// snapshot-build time, so we precompute it ONCE below (after this // snapshot-build time, so we precompute it ONCE below (after this
+1 -43
View File
@@ -259,7 +259,6 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
match &mut n.data { match &mut n.data {
NodeData::Text { contents } => { *contents = arg2.clone(); } NodeData::Text { contents } => { *contents = arg2.clone(); }
NodeData::Comment { contents } => { *contents = arg2.clone(); } NodeData::Comment { contents } => { *contents = arg2.clone(); }
NodeData::ProcessingInstruction { data, .. } => { *data = arg2.clone(); }
_ => {} _ => {}
} }
}); });
@@ -280,47 +279,6 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
"create_comment_node" => { "create_comment_node" => {
dom.new_node(NodeData::Comment { contents: arg1.clone() }).index().to_string() dom.new_node(NodeData::Comment { contents: arg1.clone() }).index().to_string()
} }
"create_processing_instruction" => {
// arg1 = target, arg2 = data
dom.new_node(NodeData::ProcessingInstruction {
target: arg1.clone(),
data: arg2.clone(),
}).index().to_string()
}
"create_doctype" => {
// arg1 = name, arg2 = public_id. system_id stored only in the
// JS wrapper since neither current WPT test reads it back from
// the underlying tree.
dom.new_node(NodeData::Doctype {
name: arg1.clone(),
public_id: arg2.clone(),
system_id: String::new(),
}).index().to_string()
}
"pi_target" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
NodeData::ProcessingInstruction { target, .. } => Some(target.clone()),
_ => None,
}).unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"doctype_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
NodeData::Doctype { name, .. } => Some(name.clone()),
_ => None,
}).unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"doctype_public_id" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom.get_node(NodeId::new(nid)).and_then(|n| match &n.data {
NodeData::Doctype { public_id, .. } => Some(public_id.clone()),
_ => None,
}).unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"element_children" => { "element_children" => {
let nid = arg1.parse::<u32>().unwrap_or(0); let nid = arg1.parse::<u32>().unwrap_or(0);
let ids: Vec<i32> = dom.children(NodeId::new(nid)).iter() let ids: Vec<i32> = dom.children(NodeId::new(nid)).iter()
@@ -768,7 +726,7 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
)); ));
} }
if scheme == "file" || obscura_net::env_allows_private_network() { if scheme == "file" {
return Ok(()); return Ok(());
} }
+1 -1
View File
@@ -129,7 +129,7 @@ impl BrowserState {
} }
} }
pub async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse { pub(crate) async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse {
match method { match method {
"initialize" => handle_initialize(id, params), "initialize" => handle_initialize(id, params),
"ping" => RpcResponse::ok(id, json!({})), "ping" => RpcResponse::ok(id, json!({})),
+2 -2
View File
@@ -25,7 +25,7 @@ wreq-util = { version = "3.0.0-rc.10", optional = true }
# Enabling it on other platforms produces unresolved `build_script_main_*` # Enabling it on other platforms produces unresolved `build_script_main_*`
# symbols at link time (see issue #39). # symbols at link time (see issue #39).
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
wreq = { version = "6.0.0-rc.28", optional = true, features = ["prefix-symbols"] } wreq = { version = "6.0.0-rc.28", optional = true, features = ["prefix-symbols", "socks"] }
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies] [target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
wreq = { version = "6.0.0-rc.28", optional = true } wreq = { version = "6.0.0-rc.28", optional = true, features = ["socks"] }
+14
View File
@@ -72,6 +72,20 @@ impl StealthHttpClient {
pub async fn fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> { pub async fn fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> {
let mut current_url = url.clone(); let mut current_url = url.clone();
if let Some(host) = current_url.host_str() {
if crate::blocklist::is_blocked(host) {
tracing::debug!("Blocked tracker: {}", current_url);
return Ok(Response {
status: 0,
url: current_url,
headers: HashMap::new(),
body: Vec::new(),
redirected_from: Vec::new(),
});
}
}
let mut redirects = Vec::new(); let mut redirects = Vec::new();
for _ in 0..20 { for _ in 0..20 {