From 3a4e3f7d7dc7d9f3f263fe6ddaea01d3680e7344 Mon Sep 17 00:00:00 2001 From: ulises2k Date: Sat, 25 Jul 2026 22:30:38 -0300 Subject: [PATCH 01/18] fix(js,dom): keep parsed SVG in the SVG namespace so href reflects an SVGAnimatedString get namespaceURI() derived the namespace from the tag name and only ever recognised the root, so every element parsed inside an subtree reported the XHTML namespace. get href() gates the SVGAnimatedString reflection on the SVG namespace, so for parsed content the gate never opened: href returned a plain string and .animVal/.baseVal were undefined, making new URL(el.href.animVal) throw. Only createElementNS-built elements worked, because createElementNS records _ns explicitly. The tree builder already applies the HTML5 foreign-content rules, so QualName.ns is correct for parsed SVG; it just was not exposed to JS. Add a namespace_uri DOM op and read it in get namespaceURI(), keeping _ns as the override and the old tag-name heuristic as the fallback when no element name is recorded. Namespaces are immutable, so the result is cached like _lname. Fixes #484 --- crates/obscura-js/js/bootstrap.js | 18 ++++++++++++++---- crates/obscura-js/src/ops.rs | 8 ++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 4a0f53f..3ef371a 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1210,11 +1210,21 @@ class Element extends Node { set className(v) { this.setAttribute("class", v); } get namespaceURI() { // createElementNS records the requested namespace on _ns; an empty string - // maps to the null namespace per spec. Elements made via createElement (or - // parsed) have no _ns: default to XHTML, except which is SVG. + // maps to the null namespace per spec. if (this._ns !== undefined) return this._ns === "" ? null : this._ns; - if (this.localName === "svg") return "http://www.w3.org/2000/svg"; - return "http://www.w3.org/1999/xhtml"; + // Otherwise use the namespace the HTML tree builder assigned. Foreign + // content puts the WHOLE / subtree in that namespace, not just + // the root, so deriving it from the tag name (the old `localName === "svg"` + // check) left every descendant looking like HTML and skipped the SVG-only + // reflections -- notably `get href()`, which then returned a plain string + // instead of an SVGAnimatedString. An element's namespace never changes, + // so cache it like _lname. + if (this._nsCache !== undefined) return this._nsCache; + let ns = _domParse("namespace_uri", this._nid) || ""; + // Nodes with no element name recorded fall back to the previous heuristic. + if (!ns) ns = this.localName === "svg" ? "http://www.w3.org/2000/svg" : "http://www.w3.org/1999/xhtml"; + this._nsCache = ns; + return ns; } // `inner_html` resolves a fixture", + ) + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}", + content_type, + body.len(), + body, + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.shutdown(std::net::Shutdown::Both); + }); + } + }); + format!("http://{}", addr) +} + +async fn run_browser(base: &str) { + let browser = Browser::new().unwrap(); + let mut page = browser.new_page().await.unwrap(); + page.goto(base).await.unwrap(); + + let script = format!( + r#"(function() {{ + var done = 0; + function mark() {{ + done += 1; + document.body.setAttribute('data-done', String(done)); + }} + for (var i = 0; i < 8; i++) {{ + fetch('{base}/api?fetch=' + i) + .then(function(r) {{ return r.json(); }}) + .then(function() {{ return fetch('{base}/api?nested=' + i); }}) + .then(mark) + .catch(function() {{}}); + }} + for (var j = 0; j < 4; j++) {{ + var xhr = new XMLHttpRequest(); + xhr.open('GET', '{base}/api?xhr=' + j); + xhr.addEventListener('load', mark); + xhr.send(); + }} + }})()"#, + ); + page.evaluate(&script); + + for _ in 0..20 { + page.settle(250).await; + if page.evaluate("document.body.getAttribute('data-done')") == serde_json::json!("12") { + break; + } + } + assert_eq!( + page.evaluate("document.body.getAttribute('data-done')"), + serde_json::json!("12") + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn sequential_browsers_complete_concurrent_fetch_and_xhr() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let base = spawn_server(); + + for _ in 0..8 { + run_browser(&base).await; + } +} From 750ffa62a99a903986b2d281d78d51c8d6f73857 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:05:04 +0000 Subject: [PATCH 05/18] fix(mcp): bound HTTP request read time --- crates/obscura-mcp/src/http.rs | 256 +++++++++++++++++++++++++-------- 1 file changed, 193 insertions(+), 63 deletions(-) diff --git a/crates/obscura-mcp/src/http.rs b/crates/obscura-mcp/src/http.rs index fad3f91..4e8c93c 100644 --- a/crates/obscura-mcp/src/http.rs +++ b/crates/obscura-mcp/src/http.rs @@ -1,7 +1,8 @@ use anyhow::Result; use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; +use tokio::time::Duration; use crate::{dispatch, BrowserState}; @@ -12,6 +13,122 @@ use crate::{dispatch, BrowserState}; /// is far above any real JSON-RPC tool call. const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; +/// Maximum time allowed to receive one complete HTTP request (request line, +/// headers, and body). The deadline is shared across all reads so a client +/// cannot keep the sequential MCP server occupied by slowly dribbling data. +const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(30); + +enum RequestBody { + NotRead, + MissingLength, + TooLarge, + Read(Vec), +} + +struct HttpRequest { + method: String, + path: String, + accept_sse: bool, + keep_alive: bool, + origin: Option, + body: RequestBody, +} + +enum RequestRead { + Closed, + Invalid, + Request(HttpRequest), +} + +async fn read_request( + reader: &mut (impl AsyncBufRead + Unpin), + allowed_origins: Option<&str>, +) -> Result { + let mut request_line = String::new(); + if reader.read_line(&mut request_line).await? == 0 { + return Ok(RequestRead::Closed); + } + let request_line = request_line.trim(); + if request_line.is_empty() { + return Ok(RequestRead::Closed); + } + + let parts: Vec<&str> = request_line.splitn(3, ' ').collect(); + if parts.len() < 3 { + return Ok(RequestRead::Invalid); + } + let method = parts[0].to_string(); + let path = parts[1].to_string(); + + let mut content_length: Option = None; + let mut accept_sse = false; + let mut keep_alive = false; + let mut origin: Option = None; + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + let trimmed = line.trim_end_matches("\r\n").trim_end_matches('\n'); + if trimmed.is_empty() { + break; + } + let lower = trimmed.to_lowercase(); + if let Some(v) = lower.strip_prefix("content-length: ") { + content_length = v.trim().parse().ok(); + } + if lower.starts_with("origin:") { + if let Some(idx) = trimmed.find(':') { + origin = Some(trimmed[idx + 1..].trim().to_string()); + } + } + if lower.contains("text/event-stream") { + accept_sse = true; + } + if lower.starts_with("connection: ") && lower.contains("keep-alive") { + keep_alive = true; + } + } + + // Only consume a body for a POST that can reach the MCP route. Invalid + // paths and forbidden origins retain the existing early-response behavior. + let body = if method == "POST" + && path == "/mcp" + && origin_allowed(origin.as_deref(), allowed_origins) + { + match content_length { + None => RequestBody::MissingLength, + // Reject the client-supplied size before allocating the body. + Some(len) if len > MAX_BODY_BYTES => RequestBody::TooLarge, + Some(len) => { + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).await?; + RequestBody::Read(body) + } + } + } else { + RequestBody::NotRead + }; + + Ok(RequestRead::Request(HttpRequest { + method, + path, + accept_sse, + keep_alive, + origin, + body, + })) +} + +async fn read_request_with_timeout( + reader: &mut (impl AsyncBufRead + Unpin), + allowed_origins: Option<&str>, + timeout: Duration, +) -> Result { + tokio::time::timeout(timeout, read_request(reader, allowed_origins)) + .await + .map_err(|_| anyhow::anyhow!("request read timed out"))? +} + /// Origin allowlist for browser callers, read from `OBSCURA_MCP_ALLOWED_ORIGINS` /// (comma-separated). Unset/empty → permissive (unchanged `*`) so hosted /// dashboards keep working (issue #175). @@ -87,52 +204,24 @@ async fn handle_connection( let mut reader = BufReader::new(reader); loop { - // ── request line ───────────────────────────────────────────────────── - let mut request_line = String::new(); - if reader.read_line(&mut request_line).await? == 0 { - break; - } - let request_line = request_line.trim().to_string(); - if request_line.is_empty() { - break; - } - - let parts: Vec<&str> = request_line.splitn(3, ' ').collect(); - if parts.len() < 3 { - break; - } - let method = parts[0]; - let path = parts[1]; - - // ── headers ────────────────────────────────────────────────────────── - let mut content_length: Option = None; - let mut accept_sse = false; - let mut keep_alive = false; - let mut origin: Option = None; - - loop { - let mut line = String::new(); - reader.read_line(&mut line).await?; - let trimmed = line.trim_end_matches("\r\n").trim_end_matches('\n'); - if trimmed.is_empty() { - break; - } - let lower = trimmed.to_lowercase(); - if let Some(v) = lower.strip_prefix("content-length: ") { - content_length = v.trim().parse().ok(); - } - if lower.starts_with("origin:") { - if let Some(idx) = trimmed.find(':') { - origin = Some(trimmed[idx + 1..].trim().to_string()); - } - } - if lower.contains("text/event-stream") { - accept_sse = true; - } - if lower.starts_with("connection: ") && lower.contains("keep-alive") { - keep_alive = true; - } - } + let request = match read_request_with_timeout( + &mut reader, + allowed_origins, + REQUEST_READ_TIMEOUT, + ) + .await? + { + RequestRead::Closed | RequestRead::Invalid => break, + RequestRead::Request(request) => request, + }; + let HttpRequest { + method, + path, + accept_sse, + keep_alive, + origin, + body, + } = request; // ── routing ────────────────────────────────────────────────────────── if path != "/mcp" { @@ -151,7 +240,7 @@ async fn handle_connection( } let cors = cors_header(origin.as_deref(), allowed_origins); - match method { + match method.as_str() { "OPTIONS" => { // mcp-protocol-version is part of the MCP spec, Authorization / // X-API-Key are common for hosted deployments. Without these @@ -199,25 +288,19 @@ async fn handle_connection( } "POST" => { - let len = match content_length { - Some(n) => n, - None => { + let body = match body { + RequestBody::MissingLength => { respond(&mut writer, 400, b"{\"error\":\"missing Content-Length\"}").await?; break; } + RequestBody::TooLarge => { + respond(&mut writer, 413, b"{\"error\":\"payload too large\"}").await?; + break; + } + RequestBody::Read(body) => body, + RequestBody::NotRead => unreachable!("valid MCP POST body was not read"), }; - // Reject oversized bodies BEFORE allocating: `vec![0u8; len]` - // commits `len` bytes up front, so an attacker-chosen - // Content-Length would otherwise OOM the process (DoS). - if len > MAX_BODY_BYTES { - respond(&mut writer, 413, b"{\"error\":\"payload too large\"}").await?; - break; - } - - let mut body = vec![0u8; len]; - reader.read_exact(&mut body).await?; - let response = process_body(&body, state).await; let bytes = serde_json::to_vec(&response)?; respond_json(&mut writer, &bytes, &cors).await?; @@ -305,7 +388,12 @@ async fn respond(writer: &mut (impl AsyncWriteExt + Unpin), status: u16, body: & #[cfg(test)] mod mcp_hardening_tests { - use super::{origin_allowed, MAX_BODY_BYTES}; + use super::{ + origin_allowed, read_request_with_timeout, MAX_BODY_BYTES, + REQUEST_READ_TIMEOUT, + }; + use tokio::io::{AsyncWriteExt, BufReader}; + use tokio::time::Duration; #[test] fn no_allowlist_is_permissive() { @@ -329,4 +417,46 @@ mod mcp_hardening_tests { assert!(MAX_BODY_BYTES >= 1 << 20); assert!(MAX_BODY_BYTES <= 64 << 20); } + + #[test] + fn request_read_timeout_is_generous_but_bounded() { + assert!(REQUEST_READ_TIMEOUT >= Duration::from_secs(10)); + assert!(REQUEST_READ_TIMEOUT <= Duration::from_secs(60)); + } + + #[tokio::test] + async fn stalled_request_line_hits_deadline() { + let (_client, server) = tokio::io::duplex(64); + let mut reader = BufReader::new(server); + let err = match read_request_with_timeout(&mut reader, None, Duration::from_millis(20)) + .await + { + Err(err) => err, + Ok(_) => panic!("stalled request line should time out"), + }; + assert!(err.to_string().contains("timed out")); + } + + #[tokio::test] + async fn stalled_request_body_hits_deadline() { + let (mut client, server) = tokio::io::duplex(64); + client + .write_all( + b"POST /mcp HTTP/1.1\r\n\ + Content-Length: 8\r\n\ + \r\n\ + {}", + ) + .await + .unwrap(); + + let mut reader = BufReader::new(server); + let err = match read_request_with_timeout(&mut reader, None, Duration::from_millis(20)) + .await + { + Err(err) => err, + Ok(_) => panic!("stalled request body should time out"), + }; + assert!(err.to_string().contains("timed out")); + } } From 78a3e997e735d03fe95588597c33cf2fbc7ec472 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:05:13 +0000 Subject: [PATCH 06/18] ci(release): publish explicit stealth artifacts --- .cargo/config.toml | 13 ++--- .github/workflows/release.yml | 79 ++++++++++++++++++++++----- README.md | 12 ++++ docs/Build-from-source.md | 13 +++++ docs/Configure-stealth-and-proxies.md | 5 +- docs/Installation.md | 4 ++ 6 files changed, 102 insertions(+), 24 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 75f6d91..cd566cd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,11 +2,8 @@ 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. This causes boring-sys's cmake step to fail -# when building with --features stealth. Setting CXXFLAGS and SDKROOT here makes -# them visible to all build scripts (including boring-sys's) so cmake can locate -# the headers. force=false lets CI or developers override via their shell env. -# Non-macOS platforms ignore SDKROOT; the -isystem path is silently skipped if -# it doesn't exist. See: https://github.com/h4ckf0r0day/obscura/issues/136 -CXXFLAGS = { value = "-isystem /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", force = false } -SDKROOT = { value = "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk", force = false } +# 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 cf439dc..74955da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,41 +50,90 @@ jobs: with: targets: ${{ matrix.target }} - - name: Build + - name: Install stealth build dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y cmake clang libclang-dev llvm-dev + + - name: Configure libclang (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + 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" + + - name: Configure libclang (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Build default release binary run: cargo build --release --target ${{ matrix.target }} - - name: Build stealth - run: cargo build --release --target ${{ matrix.target }} --features stealth - continue-on-error: true + - name: Stage default release binary + shell: bash + run: | + BIN_DIR=target/${{ matrix.target }}/release + mkdir -p dist/default + if [ -f "$BIN_DIR/obscura.exe" ]; then + cp "$BIN_DIR/obscura.exe" "$BIN_DIR/obscura-worker.exe" dist/default/ + else + cp "$BIN_DIR/obscura" "$BIN_DIR/obscura-worker" dist/default/ + fi - - name: Smoke test (V8 snapshot) + # Build and package stealth separately so the default download keeps its + # lower RSS while the advertised wreq/BoringSSL variant cannot silently + # fall back to a non-stealth binary. + - name: Build stealth release binary + run: cargo build --release --target ${{ matrix.target }} --features stealth + + - name: Stage stealth release binary + shell: bash + run: | + BIN_DIR=target/${{ matrix.target }}/release + mkdir -p dist/stealth + if [ -f "$BIN_DIR/obscura.exe" ]; then + cp "$BIN_DIR/obscura.exe" "$BIN_DIR/obscura-worker.exe" dist/stealth/ + else + cp "$BIN_DIR/obscura" "$BIN_DIR/obscura-worker" dist/stealth/ + fi + + - name: Smoke test release binaries (V8 snapshot) # Run the V8 path once so a release never ships a binary that crashes at # isolate init from a mismatched snapshot (issue #290). The data: URL # keeps it offline. Every target here is native, so the runner can - # execute what it just built. + # execute both variants it just built. shell: bash run: | - BIN=target/${{ matrix.target }}/release/obscura - [ -f "$BIN.exe" ] && BIN="$BIN.exe" - "$BIN" fetch "data:text/html,ok" --eval "1+1" + for VARIANT in default stealth; do + BIN=dist/$VARIANT/obscura + [ -f "$BIN.exe" ] && BIN="$BIN.exe" + "$BIN" fetch "data:text/html,ok" --eval "1+1" + done - name: Package (Unix) if: runner.os != 'Windows' run: | - cd target/${{ matrix.target }}/release - tar czf ../../../${{ matrix.name }}.tar.gz obscura obscura-worker - cd ../../.. + tar czf ${{ matrix.name }}.tar.gz -C dist/default obscura obscura-worker + tar czf ${{ matrix.name }}-stealth.tar.gz -C dist/stealth obscura obscura-worker - name: Package (Windows) if: runner.os == 'Windows' run: | - cd target/${{ matrix.target }}/release - 7z a ../../../${{ matrix.name }}.zip obscura.exe obscura-worker.exe - cd ../../.. + cd dist/default + 7z a ../../${{ matrix.name }}.zip obscura.exe obscura-worker.exe + cd ../stealth + 7z a ../../${{ matrix.name }}-stealth.zip obscura.exe obscura-worker.exe - name: Upload uses: softprops/action-gh-release@v2 with: files: | ${{ matrix.name }}.tar.gz + ${{ matrix.name }}-stealth.tar.gz ${{ matrix.name }}.zip + ${{ matrix.name }}-stealth.zip diff --git a/README.md b/README.md index c4d0bfd..4e0992c 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ No Chrome, no Node.js, no dependencies. Release archives include both `obscura` and `obscura-worker`; keep them in the same directory for the parallel `scrape` command. +Default archive names contain the lean binary. Archives ending in +`-stealth.tar.gz` or `-stealth.zip` additionally include the wreq/BoringSSL +transport used for TLS impersonation. + Linux release builds target Ubuntu 22.04 so the downloaded binary remains usable on common LTS servers with glibc 2.35+. @@ -192,6 +196,14 @@ cargo build --release --features stealth ``` Requires Rust 1.75+ ([rustup.rs](https://rustup.rs)). First build takes ~5 min (V8 compiles from source, cached after). +The stealth build also compiles BoringSSL and generates bindings, so it needs +CMake, Clang, and the libclang/LLVM development libraries. On Ubuntu/Debian: + +```bash +sudo apt-get install build-essential cmake clang libclang-dev llvm-dev +``` + +The default build does not require these additional stealth dependencies. ## Quick Start diff --git a/docs/Build-from-source.md b/docs/Build-from-source.md index 84be00c..db31e3c 100644 --- a/docs/Build-from-source.md +++ b/docs/Build-from-source.md @@ -24,6 +24,19 @@ cargo build --release --features stealth Adds TLS fingerprint randomization and the tracker blocklist. See [Configure stealth and proxies](Configure-stealth-and-proxies.md). +The stealth feature builds BoringSSL and generates Rust bindings. In addition +to the default requirements, install CMake, Clang, and the libclang/LLVM +development libraries. On Ubuntu/Debian: + +```bash +sudo apt-get install build-essential cmake clang libclang-dev llvm-dev +``` + +On macOS, install the Xcode Command Line Tools and CMake. On Windows, install +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. + ## OpenSSL on older systems If the build fails on the vendored OpenSSL with an AVX-512 assembler error (common on older VPS hosts): diff --git a/docs/Configure-stealth-and-proxies.md b/docs/Configure-stealth-and-proxies.md index 26a08ae..1bc222a 100644 --- a/docs/Configure-stealth-and-proxies.md +++ b/docs/Configure-stealth-and-proxies.md @@ -15,7 +15,10 @@ What `--stealth` changes: - Loads a tracker blocklist that drops requests to known analytics and fingerprinting endpoints. - Bundles webpki roots instead of relying on the system store. -Requires a build that includes the stealth feature. Release binaries on the Releases page include it. To build it yourself: +Requires a build that includes the stealth feature. The Releases page provides +explicitly named `-stealth` archives alongside the lean default archives, so +users who do not need wreq/BoringSSL do not pay its binary-size or RSS cost. To +build the stealth variant yourself: ```bash cargo build --release --features stealth diff --git a/docs/Installation.md b/docs/Installation.md index 87e3988..54624a6 100644 --- a/docs/Installation.md +++ b/docs/Installation.md @@ -59,6 +59,10 @@ See [Build from source](Build-from-source.md). - `obscura`: CLI and CDP server. - `obscura-worker`: helper for the parallel `scrape` command. Keep both in the same directory. +The standard archive names contain the lean default build. Choose the matching +archive with a `-stealth` suffix when you need the wreq/BoringSSL transport for +TLS impersonation. + ## Smoke test ```bash 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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