From 0670e738a0dea3241901430aa6d0f42699e01519 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:47:15 +0000 Subject: [PATCH 01/12] ci(release): use active macOS SDK for stealth builds --- .cargo/config.toml | 7 ------- .github/workflows/release.yml | 5 ++++- docs/Build-from-source.md | 9 +++++++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index cd566cd..8af59dd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,9 +1,2 @@ [env] RUST_TEST_THREADS = "1" - -# macOS 26 (Tahoe) moved libc++ headers inside the SDK; Apple clang 17 no longer -# finds them at the old search path. Scope the workaround to Apple targets so a -# Linux stealth build never inherits a macOS SDK include flag. cc-rs recognizes -# the target-suffixed CXXFLAGS variables below. See issue #136. -CXXFLAGS_aarch64_apple_darwin = { value = "-isystem /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", force = false } -CXXFLAGS_x86_64_apple_darwin = { value = "-isystem /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", force = false } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74955da..4a4af7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,10 +60,13 @@ jobs: if: runner.os == 'macOS' shell: bash run: | + # Use one coherent SDK. A hard-coded CommandLineTools include path can + # mix libc++ headers with the selected Xcode SDK and break BoringSSL. + XCODE_PATH="$(xcode-select -p)" SDK_PATH="$(xcrun --show-sdk-path)" echo "SDKROOT=$SDK_PATH" >> "$GITHUB_ENV" echo "CXXFLAGS=-isystem $SDK_PATH/usr/include/c++/v1" >> "$GITHUB_ENV" - echo "LIBCLANG_PATH=$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib" >> "$GITHUB_ENV" + echo "LIBCLANG_PATH=$XCODE_PATH/Toolchains/XcodeDefault.xctoolchain/usr/lib" >> "$GITHUB_ENV" - name: Configure libclang (Windows) if: runner.os == 'Windows' diff --git a/docs/Build-from-source.md b/docs/Build-from-source.md index db31e3c..10940cb 100644 --- a/docs/Build-from-source.md +++ b/docs/Build-from-source.md @@ -37,6 +37,15 @@ the Visual Studio C++ Build Tools, CMake, and LLVM/Clang. Ensure the directory containing `libclang` is available through `LIBCLANG_PATH` if bindgen cannot locate it automatically. +On macOS 26 with the standalone Command Line Tools, Apple Clang may not find +libc++ while compiling BoringSSL. Use the active SDK for that build: + +```bash +SDK_PATH="$(xcrun --show-sdk-path)" +SDKROOT="$SDK_PATH" CXXFLAGS="-isystem $SDK_PATH/usr/include/c++/v1" \ + cargo build --release --features stealth +``` + ## OpenSSL on older systems If the build fails on the vendored OpenSSL with an AVX-512 assembler error (common on older VPS hosts): From 776c915c8cf08a8b1201d2afa8b9f6e21913b192 Mon Sep 17 00:00:00 2001 From: "c.kai" Date: Mon, 27 Jul 2026 14:47:48 +0800 Subject: [PATCH 02/12] fix(js): add global PromiseRejectionEvent and StorageEvent constructors Obscura lacked these two standard event constructors on globalThis. The missing PromiseRejectionEvent caused core-js to misdetect the environment and override native Promise with a broken polyfill, breaking Vue application rendering. StorageEvent completes the localStorage/sessionStorage API surface. Both are also registered in document.createEvent() for legacy callers. Fixes #521, fixes #522. --- crates/obscura-js/js/bootstrap.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 3ef371a..c11b770 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2627,6 +2627,8 @@ class Document extends Node { 'popstateevent': PopStateEvent, 'animationevent': AnimationEvent, 'transitionevent': TransitionEvent, + 'promiserejectionevent': PromiseRejectionEvent, + 'storageevent': StorageEvent, }; const Cls = map[String(type || '').toLowerCase()] || Event; return new Cls(''); @@ -5007,6 +5009,35 @@ globalThis.ToggleEvent = class ToggleEvent extends Event { }; _markNative(globalThis.ToggleEvent); +globalThis.PromiseRejectionEvent = class PromiseRejectionEvent extends Event { + constructor(type, init = {}) { + super(type, init); + this.promise = init.promise; + this.reason = init.reason; + } +}; +_markNative(globalThis.PromiseRejectionEvent); + +globalThis.StorageEvent = class StorageEvent extends Event { + constructor(type, 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 || ""; + this.storageArea = init.storageArea || null; + } + initStorageEvent(type, bubbles, cancelable, key, oldValue, newValue, url, storageArea) { + this.initEvent(type, bubbles, cancelable); + this.key = key !== undefined ? key : null; + this.oldValue = oldValue !== undefined ? oldValue : null; + this.newValue = newValue !== undefined ? newValue : null; + this.url = url || ""; + this.storageArea = storageArea || null; + } +}; +_markNative(globalThis.StorageEvent); + // AbortController / AbortSignal. AbortSignal is a real constructor with a // prototype, so feature-detection and `AbortSignal.prototype` access work. It // carries aborted/reason, supports throwIfAborted(), and fires "abort" to From 0522831dcc60faac8b044eedbab0b771c7cbbd45 Mon Sep 17 00:00:00 2001 From: Marco Ripamonti Date: Tue, 28 Jul 2026 15:43:12 +0200 Subject: [PATCH 03/12] feat(net): trust platform CA roots alongside bundled webpki roots reqwest was pinned to rustls-tls, so HTTPS only trusted the bundled webpki roots: SSL_CERT_FILE, SSL_CERT_DIR, and the system CA store were ignored, and obscura-net exposes no add-CA hook. Deployments behind a TLS-inspecting egress proxy or an internal CA cannot fetch at all, and a caching proxy in front of --proxy cannot terminate TLS. Enable rustls-tls-native-roots alongside rustls-tls. reqwest merges the two root stores into one, so the default trust surface only grows and certificate verification is otherwise unchanged: unknown CAs are still rejected. The stealth (wreq) client has its own TLS stack and is out of scope here. Tests: an in-process HTTPS fixture with an rcgen-minted private CA, served via tokio-rustls (pinned to the ring provider so the test binary has a single rustls CryptoProvider). With SSL_CERT_FILE pointing at the CA the fetch succeeds; without it the same fixture is rejected. Fixes #525. --- Cargo.lock | 140 +++++++++++++++++++++++++++++++ Cargo.toml | 6 +- crates/obscura-net/Cargo.toml | 9 ++ crates/obscura-net/src/client.rs | 92 +++++++++++++++++++- 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc3c366..1af53b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,22 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -697,6 +713,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "0.99.20" @@ -1209,6 +1231,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1608,6 +1631,12 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1764,12 +1793,14 @@ version = "0.1.0" dependencies = [ "async-trait", "encoding_rs", + "rcgen", "reqwest", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tracing", "url", "wreq", @@ -1805,6 +1836,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "outref" version = "0.5.2" @@ -1850,6 +1887,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1961,6 +2008,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2147,6 +2200,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2206,6 +2272,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -2275,6 +2342,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2283,6 +2351,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2316,12 +2396,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.26.0" @@ -2737,6 +2849,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -3682,6 +3813,15 @@ dependencies = [ "tap", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 201344d..68696da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,11 @@ tokio-tungstenite = "0.26" # (obscura-net/src/cookies.rs) and never uses reqwest's cookie store. Pulling # it in only added the cookie/cookie_store crates, and cookie 0.18.1's blanket # From impl fails to compile under coherence on some toolchains (issue #295). -reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "socks"], default-features = false } +# "rustls-tls-native-roots" adds the platform trust store (and SSL_CERT_FILE / +# SSL_CERT_DIR) on top of the bundled webpki roots, so deployments behind a +# corporate CA or an inspecting egress proxy can trust their own roots. The +# two root sets merge, so the default trust surface only grows. +reqwest = { version = "0.12", features = ["gzip", "brotli", "deflate", "rustls-tls", "rustls-tls-native-roots", "socks"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" url = "2" diff --git a/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml index 5246806..be0598a 100644 --- a/crates/obscura-net/Cargo.toml +++ b/crates/obscura-net/Cargo.toml @@ -25,6 +25,15 @@ tempfile = "3" # Pin exact versions so the build always resolves to the API wreq_client.rs targets. wreq-util = { version = "=3.0.0-rc.12", optional = true } +[dev-dependencies] +# TLS fixtures for the native-roots tests: rcgen mints a throwaway CA + leaf, +# tokio-rustls serves one HTTPS response with them. Pinned to the ring +# provider so the test binary has a single rustls CryptoProvider (reqwest's +# rustls also uses ring; adding aws-lc-rs would make provider auto-selection +# panic). +rcgen = "0.13" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } + # `prefix-symbols` renames BoringSSL exports to avoid clashes with system OpenSSL, # but the renaming is only correct on Linux and Android (per wreq's README). # Enabling it on other platforms produces unresolved `build_script_main_*` diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index 5aa450b..02d5eba 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -764,10 +764,12 @@ pub enum ObscuraNetError { #[cfg(test)] mod ssrf_tests { - use super::{is_forbidden_ip, validate_url, SsrfGuardResolver}; + use super::{is_forbidden_ip, validate_url, ObscuraHttpClient, SsrfGuardResolver}; + use crate::cookies::CookieJar; use reqwest::dns::{Name, Resolve}; use std::net::IpAddr; use std::str::FromStr; + use std::sync::Arc; use url::Url; fn ip(s: &str) -> IpAddr { @@ -852,4 +854,92 @@ mod ssrf_tests { ), } } + + /// Mint a throwaway CA plus a 127.0.0.1 leaf it signed, and serve one + /// canned HTTPS response with the leaf on an ephemeral port. Returns the + /// port and the CA certificate as PEM. + async fn https_fixture_with_private_ca() -> (u16, String) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca_params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + + let leaf_key = rcgen::KeyPair::generate().unwrap(); + let leaf_params = + rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()]).unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + + let certs = vec![tokio_rustls::rustls::pki_types::CertificateDer::from( + leaf_cert.der().to_vec(), + )]; + let key = tokio_rustls::rustls::pki_types::PrivateKeyDer::Pkcs8( + tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from( + leaf_key.serialize_der(), + ), + ); + let config = tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut tls) = acceptor.accept(stream).await else { + return; // Handshake rejection is the point of one test. + }; + let mut buf = [0u8; 1024]; + let _ = tls.read(&mut buf).await; + let body = "private ca ok"; + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = tls.write_all(resp.as_bytes()).await; + let _ = tls.shutdown().await; + }); + } + }); + + (port, ca_cert.pem()) + } + + // The two native-roots tests set/rely on SSL_CERT_FILE, which + // rustls-native-certs reads once per process at client build. They are + // only correct under `cargo nextest` (one process per test) β€” the same + // constraint the whole workspace already has. + + #[tokio::test] + async fn native_roots_trusts_a_private_ca_via_ssl_cert_file() { + let (port, ca_pem) = https_fixture_with_private_ca().await; + let ca_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(ca_file.path(), ca_pem).unwrap(); + std::env::set_var("SSL_CERT_FILE", ca_file.path()); + + let client = + ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true); + let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap(); + let resp = client.fetch(&url).await.expect("private CA in SSL_CERT_FILE must be trusted"); + assert_eq!(resp.status, 200); + assert_eq!(resp.text(), "private ca ok"); + } + + #[tokio::test] + async fn private_ca_is_still_rejected_without_ssl_cert_file() { + // Guards against the native-roots feature weakening verification: the + // same fixture that the SSL_CERT_FILE test trusts must fail here. The + // listener is reachable (same setup), so an Err can only be TLS. + let (port, _ca_pem) = https_fixture_with_private_ca().await; + let client = + ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true); + let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap(); + assert!(client.fetch(&url).await.is_err(), "unknown CA must be rejected"); + } } From 450eaa3b885d1928b4367026d0921087016f54cc Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:27:45 +0000 Subject: [PATCH 04/12] Added sponsor logo --- assets/sponsors/thordatalogo.png | Bin 0 -> 3592 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/sponsors/thordatalogo.png diff --git a/assets/sponsors/thordatalogo.png b/assets/sponsors/thordatalogo.png new file mode 100644 index 0000000000000000000000000000000000000000..a06cf0f8359ae88858cb9c8ef6b6b31516ed9792 GIT binary patch literal 3592 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 Date: Wed, 29 Jul 2026 01:31:08 +0200 Subject: [PATCH 05/12] Update README.md --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 4e0992c..ec80482 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,26 @@ Want to sponsor? Email [hello@obscura.sh](mailto:hello@obscura.sh). 🎁 Use discount code RAPID10 to get 10% off.

+ + + + Thordata + + + + πŸš€ Obscura Γ— Thordata
+ Need more stable proxies for automation, public web scraping, SEO, or ad verification? Power your workflow with Thordata.

+ 🌍 Residential Proxies: 100M+ real residential IPs in 190+ countries
+ ♾️ Unlimited Proxies: for heavy traffic and high concurrency
+ ✨ Recently upgraded residential IP pool
+ ✨ Unlimited Concurrent Sessions
+ πŸ” Rotating & Sticky Sessions
+ πŸ“ Flexible GEO targeting
+ ⚑ Stable HTTP(S) connections

+ 🎁 Use code obscura for 10% off.

+ Get started with a 3-day free trial and test Thordata with your own workflow. + + ## Install From 6fac7f719acb994334c4a535a6a59533b614d214 Mon Sep 17 00:00:00 2001 From: lisa0314 Date: Wed, 29 Jul 2026 20:19:21 +0800 Subject: [PATCH 06/12] fix(mcp): fill React controlled inputs --- crates/obscura-mcp/src/lib.rs | 94 ++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/obscura-mcp/src/lib.rs b/crates/obscura-mcp/src/lib.rs index 9543068..4937da4 100644 --- a/crates/obscura-mcp/src/lib.rs +++ b/crates/obscura-mcp/src/lib.rs @@ -770,9 +770,9 @@ fn tool_fill(args: &Value, state: &mut BrowserState) -> Result { r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; - el.value = {val}; - el.dispatchEvent(new Event("input", {{bubbles:true}})); - el.dispatchEvent(new Event("change", {{bubbles:true}})); + globalThis.__obscura_setFieldValue(el, "value", {val}); + el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("input", {{bubbles:true}}))); + el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("change", {{bubbles:true}}))); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), @@ -796,8 +796,8 @@ fn tool_type(args: &Value, state: &mut BrowserState) -> Result { r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:element not found"; - el.value = (el.value || "") + {txt}; - el.dispatchEvent(new Event("input", {{bubbles:true}})); + globalThis.__obscura_setFieldValue(el, "value", (el.value || "") + {txt}); + el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("input", {{bubbles:true}}))); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), @@ -1329,9 +1329,9 @@ fn tool_fill_form(args: &Value, state: &mut BrowserState) -> Result format!(r#"(function(){{ var el = document.querySelector({sel}); if (!el) return "error:not found"; - el.value = {val}; - el.dispatchEvent(new Event('input', {{bubbles:true}})); - el.dispatchEvent(new Event('change', {{bubbles:true}})); + globalThis.__obscura_setFieldValue(el, 'value', {val}); + el.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}}))); + el.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); return "ok"; }})()"#, sel = serde_json::to_string(&selector).unwrap(), val = serde_json::to_string(value).unwrap()), }; @@ -1769,3 +1769,81 @@ fn tool_set_storage_state(args: &Value, state: &mut BrowserState) -> Result") + .await + .expect("test page should navigate"); + + state.page_mut().evaluate( + r#"(function () { + var root = document.getElementById('root'); + var input = document.getElementById('field'); + var descriptor = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value'); + var tracked = String(input.value); + Object.defineProperty(input, 'value', { + configurable: true, + get: function () { return descriptor.get.call(this); }, + set: function (value) { + tracked = String(value); + descriptor.set.call(this, value); + } + }); + window.__controlledState = ''; + window.__controlledUpdates = 0; + window.__lastInputTarget = ''; + window.__lastInputTrusted = false; + root.addEventListener('input', function (event) { + window.__lastInputTarget = event.target.id; + window.__lastInputTrusted = event.isTrusted; + var next = String(event.target.value); + if (next !== tracked) { + tracked = next; + window.__controlledState = next; + window.__controlledUpdates++; + } + }); + })()"#, + ); + + tool_fill( + &json!({ "selector": "#field", "value": "filled" }), + &mut state, + ) + .expect("browser_fill should succeed"); + tool_type( + &json!({ "selector": "#field", "text": "-typed" }), + &mut state, + ) + .expect("browser_type should succeed"); + tool_fill_form( + &json!({ + "fields": [{ "selector": "#field", "value": "form-filled" }] + }), + &mut state, + ) + .expect("browser_fill_form should succeed"); + + let actual = state.page_mut().evaluate( + r#"JSON.stringify({ + domValue: document.getElementById('field').value, + controlledState: window.__controlledState, + controlledUpdates: window.__controlledUpdates, + lastInputTarget: window.__lastInputTarget, + lastInputTrusted: window.__lastInputTrusted + })"#, + ); + assert_eq!( + actual, + json!(r#"{"domValue":"form-filled","controlledState":"form-filled","controlledUpdates":3,"lastInputTarget":"field","lastInputTrusted":true}"#), + ); + } +} From 487deac25f2c8d1f03d3d53c5c4ee2d66d1d42e7 Mon Sep 17 00:00:00 2001 From: h4ckf0r0day <271927676+h4ckf0r0day@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:18:23 +0200 Subject: [PATCH 07/12] Repository of the day README update --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec80482..0b0866f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@

Obscura

-

Obscura

- +

+ h4ckf0r0day%2Fobscura | Trendshift +

Documentation Website Book a demo Releases

-

The open-source headless browser for AI agents and web scraping.
Lightweight, stealthy, and built in Rust. From 0c4740a86abe650383c935a42f3293e2b3301a0e Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Fri, 31 Jul 2026 04:44:21 +0800 Subject: [PATCH 08/12] fix(js): execute dynamic data URL scripts Decode JavaScript data URLs inside the serialized classic-script queue so each body runs before its load event advances a nested chain. Fixes #520. --- .../tests/dynamic_script_onload_fires.rs | 79 +++++++++++++++++++ crates/obscura-js/js/bootstrap.js | 46 +++++++++-- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs index d80a92e..3967cdb 100644 --- a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs +++ b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs @@ -117,3 +117,82 @@ async fn dynamic_external_scripts_execute_and_fire_load() { "dynamic scripts must execute and fire load before navigation settles" ); } + +#[tokio::test(flavor = "current_thread")] +async fn dynamic_data_scripts_execute_before_chained_load_handlers() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = "session-1"; + ctx.sessions.insert(session_id.to_string(), page_id); + + cdp( + &mut ctx, + 1, + "Page.navigate", + json!({ + "url": "data:text/html,", + "waitUntil": "load" + }), + session_id, + ) + .await; + + cdp( + &mut ctx, + 2, + "Runtime.evaluate", + json!({ + "expression": r#"(function () { + var state = { + aExec: false, + aLoad: false, + bExec: false, + bLoad: false, + cExec: false, + cLoad: false + }; + window.__dataScriptState = state; + + var a = document.createElement('script'); + a.src = 'data:text/javascript,window.__dataScriptState.aExec=true'; + a.onload = function () { + state.aLoad = true; + var b = document.createElement('script'); + b.src = 'data:application/javascript,' + encodeURIComponent('window.__dataScriptState.bExec=true'); + b.onload = function () { + state.bLoad = true; + var c = document.createElement('script'); + c.src = 'data:text/javascript;base64,' + btoa('window.__dataScriptState.cExec=true'); + c.onload = function () { state.cLoad = true; }; + document.head.appendChild(c); + }; + document.head.appendChild(b); + }; + document.head.appendChild(a); + return 'kicked'; + })()"#, + "returnByValue": true, + }), + session_id, + ) + .await; + + ctx.pages[0].settle(500).await; + + let result = cdp( + &mut ctx, + 3, + "Runtime.evaluate", + json!({ + "expression": "JSON.stringify(window.__dataScriptState)", + "returnByValue": true, + }), + session_id, + ) + .await; + assert_eq!( + result["result"]["value"], + r#"{"aExec":true,"aLoad":true,"bExec":true,"bLoad":true,"cExec":true,"cLoad":true}"#, + "data URL script bodies must execute before each chained load handler" + ); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 3ef371a..cc17415 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -18,7 +18,7 @@ '__obscura_hw', '__obscura_mem', '__documentReadyState__', '__currentUrl', // internal helpers (var-declared throughout the file) - '__processDynScriptQueue', '_markNative', '_fpRand', '_fpNoise', + '__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise', '_fpCache', '_getFp', '_fp', '_splitAsciiWhitespace', '_getElementsByClassName', '_docEncoding', '_docIsUtf8', '_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase', @@ -163,6 +163,37 @@ let _fpSeed = 0; // when SPAs dynamically insert multiple