diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8275029..cf439dc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,30 +20,26 @@ jobs:
# newer glibc shipped by ubuntu-latest.
os: ubuntu-22.04
name: obscura-x86_64-linux
- cross: false
- target: aarch64-unknown-linux-gnu
# Native build on GitHub's ARM runner.
os: ubuntu-22.04-arm
name: obscura-aarch64-linux
- cross: false
- - target: aarch64-unknown-linux-gnu
- # Cross-compile fallback from x86_64 Ubuntu 22.04. Same glibc
- # 2.35 floor, useful when ARM runners are unavailable.
- os: ubuntu-22.04
- name: obscura-aarch64-linux-cross
- cross: true
- target: aarch64-apple-darwin
os: macos-latest
name: obscura-aarch64-macos
- cross: false
- target: x86_64-apple-darwin
- os: macos-latest
+ # Must run on a native Intel runner. obscura-js bakes a V8 startup
+ # snapshot at build time, and that snapshot is architecture specific.
+ # macos-latest is Apple Silicon, so building x86_64 there embeds an
+ # arm64 snapshot into the Intel binary and V8 crashes deserializing
+ # it at isolate init (issue #290). macos-15-intel is the last x86_64
+ # image (available until Aug 2027); there is no cross-build that
+ # produces a correct snapshot.
+ os: macos-15-intel
name: obscura-x86_64-macos
- cross: false
- target: x86_64-pc-windows-msvc
os: windows-latest
name: obscura-x86_64-windows
- cross: false
runs-on: ${{ matrix.os }}
@@ -54,17 +50,6 @@ jobs:
with:
targets: ${{ matrix.target }}
- - name: Install cross-compilation toolchain (aarch64-linux)
- if: matrix.cross == true
- run: |
- sudo apt-get update
- sudo apt-get install -y gcc-aarch64-linux-gnu
- mkdir -p .cargo
- cat >> .cargo/config.toml << 'CARGO_EOF'
- [target.aarch64-unknown-linux-gnu]
- linker = "aarch64-linux-gnu-gcc"
- CARGO_EOF
-
- name: Build
run: cargo build --release --target ${{ matrix.target }}
@@ -72,6 +57,17 @@ jobs:
run: cargo build --release --target ${{ matrix.target }} --features stealth
continue-on-error: true
+ - name: Smoke test (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.
+ shell: bash
+ run: |
+ BIN=target/${{ matrix.target }}/release/obscura
+ [ -f "$BIN.exe" ] && BIN="$BIN.exe"
+ "$BIN" fetch "data:text/html,
ok" --eval "1+1"
+
- name: Package (Unix)
if: runner.os != 'Windows'
run: |
diff --git a/Cargo.lock b/Cargo.lock
index eb65679..90a50a8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,18 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
-[[package]]
-name = "ahash"
-version = "0.8.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
-dependencies = [
- "cfg-if",
- "once_cell",
- "version_check",
- "zerocopy",
-]
-
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -44,6 +32,12 @@ dependencies = [
"alloc-no-stdlib",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "anstream"
version = "1.0.0"
@@ -221,9 +215,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
-version = "2.12.1"
+version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitvec"
@@ -246,31 +240,6 @@ dependencies = [
"generic-array",
]
-[[package]]
-name = "boring-sys2"
-version = "5.0.0-alpha.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669"
-dependencies = [
- "bindgen 0.72.1",
- "cmake",
- "fs_extra",
- "fslock",
-]
-
-[[package]]
-name = "boring2"
-version = "5.0.0-alpha.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28"
-dependencies = [
- "bitflags",
- "boring-sys2",
- "foreign-types",
- "libc",
- "openssl-macros",
-]
-
[[package]]
name = "brotli"
version = "8.0.3"
@@ -292,6 +261,31 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "btls"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f"
+dependencies = [
+ "bitflags",
+ "btls-sys",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+]
+
+[[package]]
+name = "btls-sys"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b1b8638a2e1c38a5ae4efa90ae57e643baec35a30d03fc5b399b893adc4954b"
+dependencies = [
+ "bindgen 0.72.1",
+ "cmake",
+ "fs_extra",
+ "fslock",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -332,9 +326,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.63"
+version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -453,35 +447,6 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f"
-[[package]]
-name = "cookie"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
-dependencies = [
- "percent-encoding",
- "time",
- "version_check",
-]
-
-[[package]]
-name = "cookie_store"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
-dependencies = [
- "cookie",
- "document-features",
- "idna",
- "log",
- "publicsuffix",
- "serde",
- "serde_derive",
- "serde_json",
- "time",
- "url",
-]
-
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -659,15 +624,6 @@ dependencies = [
"tokio",
]
-[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "powerfmt",
-]
-
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -700,15 +656,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -789,6 +736,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1019,19 +972,13 @@ dependencies = [
"crc32fast",
]
-[[package]]
-name = "hashbrown"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
-
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "foldhash 0.1.5",
]
[[package]]
@@ -1039,6 +986,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
[[package]]
name = "heck"
@@ -1069,9 +1021,9 @@ dependencies = [
[[package]]
name = "http"
-version = "1.4.1"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
@@ -1102,9 +1054,9 @@ dependencies = [
[[package]]
name = "http2"
-version = "0.5.17"
+version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "569ef7a780e853c4e1768f58a3c8168193b82cdcbab66638a0b1c6583ec5995e"
+checksum = "9d8c910a959d6fd27dd6f8fdb26509ac0f6561465cc8c5f875d45a203ab97911"
dependencies = [
"atomic-waker",
"bytes",
@@ -1113,7 +1065,6 @@ dependencies = [
"futures-sink",
"http",
"indexmap",
- "parking_lot",
"slab",
"smallvec",
"tokio",
@@ -1351,13 +1302,12 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.99"
+version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
dependencies = [
"cfg-if",
"futures-util",
- "once_cell",
"wasm-bindgen",
]
@@ -1407,12 +1357,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
-[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1428,6 +1372,15 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+[[package]]
+name = "lru"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1476,9 +1429,9 @@ dependencies = [
[[package]]
name = "memchr"
-version = "2.8.1"
+version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "minimal-lexical"
@@ -1543,12 +1496,6 @@ 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"
@@ -1864,12 +1811,6 @@ 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"
@@ -1927,22 +1868,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "psl-types"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
-
-[[package]]
-name = "publicsuffix"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
-dependencies = [
- "idna",
- "psl-types",
-]
-
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2080,9 +2005,9 @@ dependencies = [
[[package]]
name = "regex"
-version = "1.12.3"
+version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
@@ -2103,9 +2028,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.10"
+version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
@@ -2115,8 +2040,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
- "cookie",
- "cookie_store",
"futures-core",
"http",
"http-body",
@@ -2240,17 +2163,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
-[[package]]
-name = "schnellru"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649"
-dependencies = [
- "ahash",
- "cfg-if",
- "hashbrown 0.13.2",
-]
-
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2434,9 +2346,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
-version = "1.15.1"
+version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
@@ -2672,37 +2584,6 @@ dependencies = [
"cfg-if",
]
-[[package]]
-name = "time"
-version = "0.3.47"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
-dependencies = [
- "deranged",
- "itoa",
- "num-conv",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
-
-[[package]]
-name = "time-macros"
-version = "0.2.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -2746,12 +2627,12 @@ dependencies = [
]
[[package]]
-name = "tokio-boring2"
-version = "5.0.0-alpha.13"
+name = "tokio-btls"
+version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756"
+checksum = "2e1fd638ec35427faf3b8f412e0fdd6fae76591d79dba40f38fa667d22bc44dd"
dependencies = [
- "boring2",
+ "btls",
"tokio",
]
@@ -3030,9 +2911,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.23.2"
+version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -3090,9 +2971,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.3+wasi-0.2.9"
+version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
@@ -3108,9 +2989,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.122"
+version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [
"cfg-if",
"once_cell",
@@ -3121,9 +3002,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.72"
+version = "0.4.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
+checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3131,9 +3012,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.122"
+version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3141,9 +3022,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.122"
+version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3154,9 +3035,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.122"
+version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [
"unicode-ident",
]
@@ -3207,9 +3088,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.99"
+version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3550,16 +3431,13 @@ dependencies = [
[[package]]
name = "wreq"
-version = "6.0.0-rc.28"
+version = "6.0.0-rc.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935"
+checksum = "3f0eba5f5814a94e5f1a99156f187133464e525b66bdbc69a9627d46530af2e1"
dependencies = [
- "ahash",
- "boring2",
- "brotli",
+ "btls",
+ "btls-sys",
"bytes",
- "flate2",
- "futures-channel",
"futures-util",
"http",
"http-body",
@@ -3568,29 +3446,62 @@ dependencies = [
"httparse",
"ipnet",
"libc",
+ "lru",
"percent-encoding",
"pin-project-lite",
- "schnellru",
- "smallvec",
"socket2",
"tokio",
- "tokio-boring2",
+ "tokio-btls",
"tokio-socks",
"tower",
"url",
- "want",
"webpki-root-certs",
- "zstd",
+ "wreq-proto",
+ "wreq-rt",
+]
+
+[[package]]
+name = "wreq-proto"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a43942f024bb303f1042c9aa3c87fa1d9149f507c65db6e5220a11ccdb207387"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "http2",
+ "httparse",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+ "want",
+]
+
+[[package]]
+name = "wreq-rt"
+version = "0.2.2-rc.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99e9bce67a3fa3dd3f1503f066d86661c9caf399a763d3bd184da7afaf886c8b"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+ "wreq-proto",
]
[[package]]
name = "wreq-util"
-version = "3.0.0-rc.10"
+version = "3.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c6bbe24d28beb9ceb58b514bd6a613c759d3b706f768b9d2950d5d35b543c04"
+checksum = "baa5d2ab72139256916ca352a3d05c53d74e1dd360052eb5ba7691033c417c65"
dependencies = [
+ "brotli",
+ "flate2",
"typed-builder",
"wreq",
+ "zstd",
]
[[package]]
@@ -3633,18 +3544,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.50"
+version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.50"
+version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
@@ -3674,9 +3585,9 @@ dependencies = [
[[package]]
name = "zeroize"
-version = "1.8.2"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
index f8de046..201344d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,11 @@ servo_arc = "0.4"
cssparser = "0.34"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.26"
-reqwest = { version = "0.12", features = ["cookies", "gzip", "brotli", "deflate", "rustls-tls", "socks"], default-features = false }
+# No "cookies" feature: obscura manages cookies through its own CookieJar
+# (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 }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index 457a3a5..0a47482 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -140,6 +140,12 @@ pub struct NetworkEvent {
pub timestamp: f64,
}
+#[derive(Debug, Clone)]
+pub struct StoredResponseBody {
+ pub body: String,
+ pub base64_encoded: bool,
+}
+
pub struct Page {
pub id: String,
pub frame_id: String,
@@ -161,6 +167,8 @@ pub struct Page {
pub history: Vec,
pub history_index: usize,
pub network_events: Vec,
+ response_bodies: std::collections::HashMap,
+ response_body_order: std::collections::VecDeque,
network_event_counter: u32,
pub intercept_enabled: bool,
pub intercept_block_patterns: Vec,
@@ -213,6 +221,8 @@ impl Page {
history: Vec::new(),
history_index: 0,
network_events: Vec::new(),
+ response_bodies: std::collections::HashMap::new(),
+ response_body_order: std::collections::VecDeque::new(),
network_event_counter: 0,
intercept_enabled: false,
intercept_block_patterns: Vec::new(),
@@ -275,19 +285,32 @@ impl Page {
#[cfg(feature = "stealth")]
if self.stealth_client.is_some() {
+ rt.set_stealth(true);
rt.set_user_agent(obscura_net::STEALTH_USER_AGENT);
- } else if let Ok(ua) = self.http_client.user_agent.try_read() {
- rt.set_user_agent(&ua);
+ // Match platform to the Linux UA so navigator.platform and
+ // userAgentData.platform are consistent with navigator.userAgent.
+ rt.set_platform("Linux x86_64", "Linux", "");
+ } else {
+ if let Ok(ua) = self.http_client.user_agent.try_read() {
+ rt.set_user_agent(&ua);
+ }
+ rt.set_platform(
+ &self.context.platform,
+ &self.context.ua_platform,
+ &self.context.ua_platform_version,
+ );
}
#[cfg(not(feature = "stealth"))]
- if let Ok(ua) = self.http_client.user_agent.try_read() {
- rt.set_user_agent(&ua);
+ {
+ if let Ok(ua) = self.http_client.user_agent.try_read() {
+ rt.set_user_agent(&ua);
+ }
+ rt.set_platform(
+ &self.context.platform,
+ &self.context.ua_platform,
+ &self.context.ua_platform_version,
+ );
}
- rt.set_platform(
- &self.context.platform,
- &self.context.ua_platform,
- &self.context.ua_platform_version,
- );
if let Some((lat, lon)) = env_geolocation() {
rt.set_geolocation(lat, lon);
}
@@ -303,6 +326,8 @@ impl Page {
rt.set_dom(dom);
}
+ rt.run_page_init();
+
self.js = Some(rt);
}
@@ -576,7 +601,7 @@ impl Page {
if script.src.is_some() {
if let Some((url, code, resp)) = fetched.remove(&i) {
tracing::info!("Executing script ({} bytes): {}", code.len(), url);
- self.record_network_event(&url, "GET", "Script", resp.status, &resp.headers, resp.body.len());
+ self.record_network_event_with_body(&url, "GET", "Script", resp.status, &resp.headers, &resp.body, false);
if let Some(js) = &mut self.js {
let _ = js.execute_script("", &format!("globalThis.__currentScriptNid={};", script.nid));
if let Err(e) = js.execute_script_guarded(&url, &code) {
@@ -948,13 +973,14 @@ impl Page {
PageError::NetworkError(e.to_string())
})?;
- self.record_network_event(
+ self.record_network_event_with_body(
url.as_str(),
"GET",
"Document",
response.status,
&response.headers,
- response.body.len(),
+ &response.body,
+ false,
);
if !response.redirected_from.is_empty() {
@@ -1048,7 +1074,7 @@ impl Page {
// CSS bodies: honor the Content-Type charset; CSS @charset is
// out of scope for the current scrape-focused pipeline.
let css = obscura_net::decode_non_html(&resp.body, resp.content_type());
- self.record_network_event(&url_str, "GET", "Stylesheet", resp.status, &resp.headers, resp.body.len());
+ self.record_network_event_with_body(&url_str, "GET", "Stylesheet", resp.status, &resp.headers, &resp.body, false);
css_sources.push(css);
}
}
@@ -1341,13 +1367,47 @@ impl Page {
response_headers: &std::collections::HashMap,
body_size: usize,
) {
+ self.record_network_event_inner(url, method, resource_type, status, response_headers, body_size);
+ }
+
+ fn record_network_event_with_body(
+ &mut self,
+ url: &str,
+ method: &str,
+ resource_type: &str,
+ status: u16,
+ response_headers: &std::collections::HashMap,
+ body: &[u8],
+ base64_encoded: bool,
+ ) {
+ let request_id = self.record_network_event_inner(
+ url,
+ method,
+ resource_type,
+ status,
+ response_headers,
+ body.len(),
+ );
+ self.store_response_body(request_id, body, base64_encoded);
+ }
+
+ fn record_network_event_inner(
+ &mut self,
+ url: &str,
+ method: &str,
+ resource_type: &str,
+ status: u16,
+ response_headers: &std::collections::HashMap,
+ body_size: usize,
+ ) -> String {
self.network_event_counter += 1;
+ let request_id = format!("{}.{}", self.id, self.network_event_counter);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
self.network_events.push(NetworkEvent {
- request_id: format!("{}.{}", self.id, self.network_event_counter),
+ request_id: request_id.clone(),
url: url.to_string(),
method: method.to_string(),
resource_type: resource_type.to_string(),
@@ -1357,6 +1417,46 @@ impl Page {
body_size,
timestamp,
});
+ request_id
+ }
+
+ fn store_response_body(&mut self, request_id: String, body: &[u8], base64_encoded: bool) {
+ let max_entries = response_body_entry_limit();
+ let max_bytes = response_body_byte_limit();
+ if max_entries == 0 || max_bytes == 0 || body.len() > max_bytes {
+ return;
+ }
+ let body = if base64_encoded {
+ BASE64.encode(body)
+ } else {
+ String::from_utf8_lossy(body).to_string()
+ };
+ self.response_bodies.insert(request_id.clone(), StoredResponseBody { body, base64_encoded });
+ self.response_body_order.push_back(request_id);
+ while self.response_body_order.len() > max_entries {
+ if let Some(oldest) = self.response_body_order.pop_front() {
+ self.response_bodies.remove(&oldest);
+ }
+ }
+ }
+
+ pub fn get_response_body(&self, request_id: &str) -> Option {
+ self.response_bodies.get(request_id).cloned().or_else(|| {
+ self.js.as_ref()?.get_network_response_body(request_id).map(|body| {
+ StoredResponseBody {
+ body: body.body,
+ base64_encoded: body.base64_encoded,
+ }
+ })
+ })
+ }
+
+ pub fn clear_response_bodies(&mut self) {
+ self.response_bodies.clear();
+ self.response_body_order.clear();
+ if let Some(js) = &self.js {
+ js.clear_network_response_bodies();
+ }
}
pub fn execute_preload_script(&mut self, source: &str) -> Result<(), String> {
@@ -1463,3 +1563,17 @@ impl From for PageError {
PageError::NetworkError(e.to_string())
}
}
+
+fn response_body_entry_limit() -> usize {
+ std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(128)
+}
+
+fn response_body_byte_limit() -> usize {
+ std::env::var("OBSCURA_NETWORK_BODY_BUFFER_BYTES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(2 * 1024 * 1024)
+}
diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs
index 8feccae..93f6b50 100644
--- a/crates/obscura-cdp/src/dispatch.rs
+++ b/crates/obscura-cdp/src/dispatch.rs
@@ -238,6 +238,7 @@ fn is_v8_free_method(method: &str) -> bool {
| "Network.getCookies" | "Network.getAllCookies"
| "Network.setCookie" | "Network.setCookies"
| "Network.deleteCookies" | "Network.clearBrowserCookies"
+ | "Network.getResponseBody"
| "Fetch.continueRequest" | "Fetch.fulfillRequest"
| "Fetch.failRequest" | "Fetch.getResponseBody"
| "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies"
diff --git a/crates/obscura-cdp/src/domains/network.rs b/crates/obscura-cdp/src/domains/network.rs
index b9172d3..ec50bf8 100644
--- a/crates/obscura-cdp/src/domains/network.rs
+++ b/crates/obscura-cdp/src/domains/network.rs
@@ -32,6 +32,16 @@ pub async fn handle(
) -> Result {
match method {
"enable" => Ok(json!({})),
+ "disable" => {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ page.clear_response_bodies();
+ } else {
+ for page in &mut ctx.pages {
+ page.clear_response_bodies();
+ }
+ }
+ Ok(json!({}))
+ }
"setExtraHTTPHeaders" => {
let headers = params.get("headers").and_then(|v| v.as_object());
if let Some(page) = ctx.get_session_page(session_id) {
@@ -86,6 +96,26 @@ pub async fn handle(
}
"setCacheDisabled" => Ok(json!({})),
"setRequestInterception" => Ok(json!({})),
+ "getResponseBody" => {
+ let request_id = params
+ .get("requestId")
+ .and_then(|v| v.as_str())
+ .ok_or("Network.getResponseBody requires requestId")?;
+
+ let body = if let Some(page) = ctx.get_session_page(session_id) {
+ page.get_response_body(request_id)
+ } else {
+ ctx.pages.iter().find_map(|page| page.get_response_body(request_id))
+ };
+
+ match body {
+ Some(body) => Ok(json!({
+ "body": body.body,
+ "base64Encoded": body.base64_encoded,
+ })),
+ None => Err(format!("No response body found for requestId {}", request_id)),
+ }
+ }
_ => Err(format!("Unknown Network method: {}", method)),
}
}
@@ -193,6 +223,75 @@ mod tests {
.expect("clearBrowserCookies must succeed");
assert!(ctx.default_context.cookie_jar.get_all_cookies().is_empty());
}
+
+ #[tokio::test]
+ async fn get_response_body_returns_stored_document_body() {
+ let mut ctx = CdpContext::new();
+ let page_id = ctx.create_page();
+ let session_id = Some("session-1".to_string());
+ ctx.sessions.insert(session_id.clone().unwrap(), page_id.clone());
+
+ let page = ctx.get_page_mut(&page_id).unwrap();
+ page.navigate("data:text/html,hello body")
+ .await
+ .unwrap();
+ let request_id = page.network_events[0].request_id.clone();
+
+ let result = handle(
+ "getResponseBody",
+ &json!({ "requestId": request_id }),
+ &mut ctx,
+ &session_id,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(result["body"], "hello body");
+ assert_eq!(result["base64Encoded"], false);
+ }
+
+ #[tokio::test]
+ async fn get_response_body_errors_for_unknown_request_id() {
+ let mut ctx = CdpContext::new();
+ let err = handle(
+ "getResponseBody",
+ &json!({ "requestId": "missing" }),
+ &mut ctx,
+ &None,
+ )
+ .await
+ .unwrap_err();
+
+ assert!(err.contains("missing"));
+ }
+
+ #[tokio::test]
+ async fn network_disable_clears_stored_response_bodies() {
+ let mut ctx = CdpContext::new();
+ let page_id = ctx.create_page();
+ let session_id = Some("session-1".to_string());
+ ctx.sessions.insert(session_id.clone().unwrap(), page_id.clone());
+
+ let page = ctx.get_page_mut(&page_id).unwrap();
+ page.navigate("data:text/html,temporary body")
+ .await
+ .unwrap();
+ let request_id = page.network_events[0].request_id.clone();
+
+ handle("disable", &json!({}), &mut ctx, &session_id)
+ .await
+ .unwrap();
+
+ let err = handle(
+ "getResponseBody",
+ &json!({ "requestId": request_id }),
+ &mut ctx,
+ &session_id,
+ )
+ .await
+ .unwrap_err();
+ assert!(err.contains("No response body found"));
+ }
}
pub(crate) fn cookie_info_to_cdp_json(c: &obscura_net::CookieInfo) -> Value {
diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs
index 8bb2847..8ede02f 100644
--- a/crates/obscura-dom/src/selector.rs
+++ b/crates/obscura-dom/src/selector.rs
@@ -565,11 +565,11 @@ impl DomTree {
// querySelector matches strict descendants of root only, so the
// indexed element must have root among its ancestors.
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
- // No element has this id at all: a bare id selector cannot match.
- None => return Ok(None),
- // Indexed (first) element is not under a scoped root; a later
- // duplicate could still be a descendant, so fall through to scan.
- Some(_) => {}
+ // Index miss or stale entry (detached node) — fall through to full scan.
+ // The id_index is best-effort: it only registers nodes at creation time
+ // and does not update on reparent, so it can point to a detached clone
+ // while the live node (with the same id) is elsewhere in the tree.
+ _ => {}
}
}
let selector_list = parse_selector(selector)?;
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 1ba9463..bee612e 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -1,5 +1,47 @@
"use strict";
+// Pre-declare all internal globals as non-enumerable so they are invisible
+// to Object.keys(window) / for-in enumeration. Must run before any var
+// declarations or property assignments below: once a property is defined
+// with enumerable:false here, subsequent `var x = value` assignments will
+// find the property already exists and only update the value, leaving the
+// descriptor intact. Direct globalThis.x = value assignments also only
+// update the value without touching enumerable when the property is
+// writable:true and configurable:true.
+(function _preHideInternals() {
+ var _names = [
+ // runtime-set by Rust (runtime.rs / page.rs)
+ '__obscura_errors', '__obscura_init', '__obscura_hide_list',
+ '__obscura_objects', '__obscura_oid', '__obscura_ua',
+ '__obscura_platform', '__obscura_ua_platform', '__obscura_ua_platform_version',
+ '__obscura_stealth',
+ '__documentReadyState__', '__currentUrl',
+ // internal helpers (var-declared throughout the file)
+ '__processDynScriptQueue', '_markNative', '_fpRand', '_fpNoise',
+ '_fpCache', '_getFp', '_fp', '_splitAsciiWhitespace',
+ '_getElementsByClassName', '_docEncoding', '_docIsUtf8',
+ '_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
+ '_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
+ '_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
+ '_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl',
+ '_resolveUrl', '_registerIframe', '_base64ToUint8Array',
+ '_bodyToUint8Array', '_arrayBufferFromBytes',
+ '_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp',
+ '_urlResolveOp', '_decodeBodyWithCharset', '_utf8DecodeBytes',
+ '_selectionFor', '_isConstructorCE', '_isValidCustomElementName',
+ '_blobPartToBytes', '_bytesToBinaryString', '_formEncode', '_hexv',
+ '_commonFonts', '_isXMLDocument', '_isValidPITarget', '_isHTMLEl',
+ '_nodeList', '_rngNodeLength', '_rngNodeIndex', '_rngSame', '_rngRoot',
+ '_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset',
+ '_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase',
+ '_makeListenerBox',
+ ];
+ var _desc = { value: undefined, writable: true, enumerable: false, configurable: true };
+ for (var _i = 0; _i < _names.length; _i++) {
+ try { Object.defineProperty(globalThis, _names[_i], _desc); } catch (_e) {}
+ }
+})();
+
globalThis.__obscura_errors = [];
globalThis.addEventListener = globalThis.addEventListener || function(){};
@@ -212,7 +254,13 @@ const _consoleFn = (level, args) => {
try { Deno.core.ops.op_console_msg(level, args.map(a => {
if (a === null) return "null";
if (a === undefined) return "undefined";
- if (a instanceof Error) return a.stack || a.message || String(a);
+ if (a instanceof Error) {
+ const _pst = Error.prepareStackTrace;
+ if (_pst !== undefined) Error.prepareStackTrace = undefined;
+ const _s = a.stack || a.message || String(a);
+ if (_pst !== undefined) Error.prepareStackTrace = _pst;
+ return _s;
+ }
if (typeof a === "object") {
try {
const s = JSON.stringify(a);
@@ -1024,6 +1072,7 @@ class Element extends Node {
removeAttributeNS(ns, n) { _dom("remove_attribute", this._nid, String(n)); }
hasAttribute(n) { return this.getAttribute(n) !== null; }
hasAttributes() { return true; } // Simplified
+ getAttributeNames() { return _domParse("attribute_names", this._nid) || []; }
get attributes() {
const el = this;
const names = _domParse("attribute_names", el._nid) || [];
@@ -1580,7 +1629,7 @@ class Element extends Node {
el._iframeDoc = new _IframeDocument('', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
}
- _registerIframe(el);
+
if (typeof el.onload === 'function') {
try { el.onload(); } catch(e) {}
} else {
@@ -1590,7 +1639,7 @@ class Element extends Node {
}).catch(() => {
el._iframeDoc = new _IframeDocument('', fullUrl, el);
el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl);
- _registerIframe(el);
+
if (typeof el.onload === 'function') try { el.onload(); } catch(e) {}
});
}
@@ -1613,7 +1662,8 @@ class Element extends Node {
get contentWindow() {
if (this.localName !== 'iframe') return undefined;
if (!this._iframeWin) {
- this.contentDocument; // side effect: creates _iframeDoc + _iframeWin
+ if (this.parentNode === null) return null;
+ this.contentDocument;
}
return this._iframeWin;
}
@@ -1754,7 +1804,7 @@ class Element extends Node {
toJSON() { return this; },
};
}
- getClientRects() { return [this.getBoundingClientRect()]; }
+ getClientRects() { return new DOMRectList([this.getBoundingClientRect()]); }
// No layout engine: a stub that always returns true unblocks Playwright's
// actionability polling. With a real layout we'd check display, visibility,
// opacity and rect dimensions per spec.
@@ -2290,6 +2340,9 @@ class Document extends Node {
}
class DocumentFragment extends Node {
+ constructor(nid) {
+ super(nid !== undefined ? nid : +_dom("create_document_fragment"));
+ }
get nodeType() { return 11; }
get nodeName() { return "#document-fragment"; }
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
@@ -2342,9 +2395,51 @@ class DocumentType extends Node {
}
const _cache = new Map();
+
+// Media elements need canPlayType for codec detection fingerprinting.
+// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
+class HTMLMediaElement extends Element {
+ canPlayType(type) {
+ if (!type || typeof type !== 'string') return '';
+ const mime = type.split(';')[0].trim().toLowerCase();
+ if (mime === 'video/mp4' || mime === 'video/webm' || mime === 'video/ogg') return 'probably';
+ if (mime === 'video/x-matroska') return 'maybe';
+ if (mime === 'audio/ogg' || mime === 'audio/webm' || mime === 'audio/wav' ||
+ mime === 'audio/mpeg') return 'probably';
+ if (mime === 'audio/mp4' || mime === 'audio/x-m4a' || mime === 'audio/aac') return 'maybe';
+ return '';
+ }
+ load() {}
+ play() { return Promise.resolve(); }
+ pause() {}
+ get paused() { return true; }
+ get ended() { return false; }
+ get readyState() { return 0; }
+ get currentTime() { return 0; }
+ set currentTime(v) {}
+ get duration() { return NaN; }
+ get volume() { return 1; }
+ set volume(v) {}
+ get muted() { return false; }
+ set muted(v) {}
+ get src() { return this.getAttribute('src') || ''; }
+ set src(v) { this.setAttribute('src', v); }
+}
+_markNative(HTMLMediaElement.prototype.canPlayType);
+_markNative(HTMLMediaElement.prototype.play);
+_markNative(HTMLMediaElement.prototype.load);
+_markNative(HTMLMediaElement.prototype.pause);
+class HTMLVideoElement extends HTMLMediaElement {}
+class HTMLAudioElement extends HTMLMediaElement {}
+globalThis.HTMLMediaElement = HTMLMediaElement;
+globalThis.HTMLVideoElement = HTMLVideoElement;
+globalThis.HTMLAudioElement = HTMLAudioElement;
+
function _elementClassFor(nid) {
const tag = _domParse("tag_name", nid);
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
+ if (tag === "AUDIO") return HTMLAudioElement;
+ if (tag === "VIDEO") return HTMLVideoElement;
return Element;
}
function _wrap(nid) {
@@ -2449,17 +2544,74 @@ Object.defineProperty(globalThis.Window, Symbol.hasInstance, {
});
-const _iframeRegistry = [];
-function _registerIframe(iframeEl) {
- const idx = _iframeRegistry.length;
- _iframeRegistry.push(iframeEl);
- globalThis.length = _iframeRegistry.length;
- Object.defineProperty(globalThis, idx, {
- get() { return iframeEl._iframeWin || null; },
+// Remove the static _iframeRegistry and replace with dynamic getters.
+Object.defineProperty(globalThis, 'length', {
+ get() {
+ return document.querySelectorAll('iframe').length;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+// Since we cannot define a Proxy on globalThis easily, we'll define a reasonable number of indexed getters.
+for (let i = 0; i < 50; i++) {
+ Object.defineProperty(globalThis, i, {
+ get() {
+ const iframes = document.querySelectorAll('iframe');
+ if (i < iframes.length) {
+ return iframes[i].contentWindow;
+ }
+ return undefined;
+ },
configurable: true,
- enumerable: false,
+ enumerable: false
});
}
+
+// Navigator constructor so that typeof Navigator !== 'undefined' and
+// navigatorPrototype checks don't throw a ReferenceError.
+function Navigator() {}
+_markNative(Navigator);
+
+// PluginArray must exist before navigator is built so the plugins getter can use it.
+function PluginArray(items) {
+ for (var _pi = 0; _pi < items.length; _pi++) this[_pi] = items[_pi];
+ this.length = items.length;
+}
+PluginArray.prototype = Object.create(Array.prototype);
+PluginArray.prototype.constructor = PluginArray;
+PluginArray.prototype.item = function(i) { return this[i] || null; };
+PluginArray.prototype.namedItem = function(name) {
+ for (var _pi = 0; _pi < this.length; _pi++) {
+ if (this[_pi].name === name) return this[_pi];
+ }
+ return null;
+};
+PluginArray.prototype.refresh = function() {};
+PluginArray.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
+Object.defineProperty(PluginArray.prototype, Symbol.toStringTag, {value: 'PluginArray', configurable: true});
+_markNative(PluginArray);
+_markNative(PluginArray.prototype.item);
+_markNative(PluginArray.prototype.namedItem);
+_markNative(PluginArray.prototype.refresh);
+
+class NetworkInformation {
+ get downlink() { return 10; }
+ get downlinkMax() { return Infinity; }
+ get effectiveType() { return '4g'; }
+ get rtt() { return 50; }
+ get saveData() { return false; }
+ get type() { return 'wifi'; }
+ get onchange() { return null; }
+ set onchange(v) {}
+ get ontypechange() { return null; }
+ set ontypechange(v) {}
+}
+_markNative(NetworkInformation);
+globalThis.NetworkInformation = NetworkInformation;
+
+globalThis.ContentIndex = class ContentIndex {};
+
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 appVersion() { return this.userAgent.replace('Mozilla/', ''); },
@@ -2469,21 +2621,16 @@ globalThis.navigator = {
vendor: "Google Inc.", product: "Gecko", productSub: "20030107",
doNotTrack: null,
deviceMemory: 8,
- connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false, onchange: null, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} },
- get webdriver() { return false; },
+ connection: new NetworkInformation(),
pdfViewerEnabled: true,
get plugins() {
- const p = [
- { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 },
- { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 },
- { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 },
- { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 },
- { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 },
- ];
- p.item = (i) => p[i] || null;
- p.namedItem = (name) => p.find(x => x.name === name) || null;
- p.refresh = () => {};
- p[Symbol.iterator] = Array.prototype[Symbol.iterator].bind(p);
+ const p = new PluginArray([
+ { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" },
+ { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" },
+ { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" },
+ { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" },
+ { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1, [Symbol.toStringTag]: "Plugin" },
+ ]);
return p;
},
get mimeTypes() {
@@ -2499,7 +2646,7 @@ globalThis.navigator = {
brands: [
{brand: "Google Chrome", version: "145"},
{brand: "Chromium", version: "145"},
- {brand: "Not=A?Brand", version: "24"},
+ {brand: "Not;A=Brand", version: "24"},
],
mobile: false,
get platform() { return globalThis.__obscura_ua_platform || "Windows"; },
@@ -2507,8 +2654,8 @@ globalThis.navigator = {
return Promise.resolve({
architecture: "x86",
bitness: "64",
- brands: [{brand:"Google Chrome",version:"145"},{brand:"Chromium",version:"145"},{brand:"Not=A?Brand",version:"24"}],
- fullVersionList: [{brand:"Google Chrome",version:"145.0.0.0"},{brand:"Chromium",version:"145.0.0.0"},{brand:"Not=A?Brand",version:"24.0.0.0"}],
+ brands: [{brand:"Google Chrome",version:"145"},{brand:"Chromium",version:"145"},{brand:"Not;A=Brand",version:"24"}],
+ fullVersionList: [{brand:"Google Chrome",version:"145.0.0.0"},{brand:"Chromium",version:"145.0.0.0"},{brand:"Not;A=Brand",version:"24.0.0.0"}],
mobile: false,
model: "",
platform: globalThis.__obscura_ua_platform || "Windows",
@@ -2579,9 +2726,21 @@ globalThis.navigator = {
},
};
+// Move navigator.webdriver off the instance and onto a thin prototype so that
+// Object.getOwnPropertyDescriptor(navigator, 'webdriver') returns undefined,
+// matching Chrome where the property lives on Navigator.prototype.
+// Use Navigator.prototype as the chain base so navigator instanceof Navigator === true.
+(function() {
+ var _wdGetter = function() { return false; };
+ _markNative(_wdGetter);
+ var _wdProto = Object.create(Navigator.prototype);
+ Object.defineProperty(_wdProto, 'webdriver', {get: _wdGetter, set: undefined, enumerable: true, configurable: true});
+ Object.setPrototypeOf(globalThis.navigator, _wdProto);
+})();
+
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" } },
- runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} },
+ runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { throw new Error("Could not establish connection. Receiving end does not exist."); }, sendMessage() { throw new Error("Could not establish connection. Receiving end does not exist."); } },
csi() {
const t = Date.now();
return { onloadT: t, startE: t - Math.floor(100 + _fpRand(610) * 200), pageT: 0, tran: 5, flashVersion: "" };
@@ -2616,7 +2775,23 @@ globalThis.Notification = class Notification {
globalThis.WebGLRenderingContext = class WebGLRenderingContext {};
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;}} };
+class Screen {
+ constructor(w, h) {
+ this._w = w; this._h = h;
+ this.colorDepth = 24; this.pixelDepth = 24; this.availTop = 0; this.availLeft = 0;
+ this.orientation = {type:'landscape-primary',angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}};
+ }
+ get width() { return this._w; }
+ get height() { return this._h; }
+ get availWidth() { return this._w; }
+ get availHeight() { return this._h - 40; }
+}
+['width','height','availWidth','availHeight'].forEach(function(k) {
+ var d = Object.getOwnPropertyDescriptor(Screen.prototype, k);
+ if (d && d.get) _markNative(d.get);
+});
+globalThis.Screen = Screen;
+globalThis.screen = new Screen(1920, 1080);
globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = 1;
globalThis.innerWidth = 1920; globalThis.innerHeight = 1000;
@@ -2722,7 +2897,7 @@ globalThis.fetch = async (input, init = {}) => {
}
const respType = parsed.status === 0 ? "opaque" : (fetchMode === "no-cors" ? "opaque" : "basic");
const responseBody = parsed.bodyBase64 ? _base64ToUint8Array(parsed.bodyBase64) : (parsed.body || "");
- return new Response(responseBody, {
+ const response = new Response(responseBody, {
status: parsed.status,
statusText: "",
headers: parsed.headers || {},
@@ -2730,6 +2905,13 @@ globalThis.fetch = async (input, init = {}) => {
url: parsed.url || url,
redirected: false,
});
+ if (parsed.requestId) {
+ Object.defineProperty(response, "__obscuraRequestId", {
+ value: parsed.requestId,
+ configurable: true,
+ });
+ }
+ return response;
};
if (typeof Headers === "undefined") {
@@ -3318,7 +3500,29 @@ if (typeof TextDecoder === 'undefined') {
};
}
-globalThis.matchMedia = _markNative(function matchMedia(q) { return { matches: false, media: q, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} }; });
+globalThis.matchMedia = _markNative(function matchMedia(q) {
+ var s = (q || '').toLowerCase().replace(/\s+/g, '');
+ var matches = false;
+ if (s.includes('prefers-color-scheme:light')) matches = false;
+ else if (s.includes('prefers-color-scheme:dark')) matches = true;
+ else if (s.includes('prefers-reduced-motion:no-preference')) matches = true;
+ else if (s.includes('prefers-reduced-motion:reduce')) matches = false;
+ else if (s.includes('any-pointer:fine')) matches = true;
+ else if (s.includes('any-pointer:coarse')) matches = false;
+ else if (s.includes('pointer:fine')) matches = true;
+ else if (s.includes('hover:hover')) matches = true;
+ else if (s.includes('any-hover:hover')) matches = true;
+ else if (s.includes('color)') || s === '(color)') matches = true;
+ else if (s.includes('min-width')) {
+ var m = s.match(/min-width:\s*(\d+)px/);
+ matches = m ? (globalThis.innerWidth || 1440) >= parseInt(m[1]) : false;
+ }
+ else if (s.includes('max-width')) {
+ var m2 = s.match(/max-width:\s*(\d+)px/);
+ matches = m2 ? (globalThis.innerWidth || 1440) <= parseInt(m2[1]) : false;
+ }
+ return { matches: matches, media: q, onchange: null, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} };
+});
globalThis.getComputedStyle = (el) => {
if (!el) el = document.body || {};
const style = el?.style || el?._style || new CSSStyleDeclaration();
@@ -4283,7 +4487,14 @@ globalThis.XMLSerializer = class XMLSerializer {
}
};
globalThis.performance = globalThis.performance || {
- now: () => Date.now(),
+ now: (function() {
+ var _lastMs = -1, _sub = 0;
+ return function() {
+ var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
+ if (ms !== _lastMs) { _lastMs = ms; _sub = 0; } else { _sub += 0.1; }
+ return ms + _sub;
+ };
+ })(),
mark(){}, measure(){},
clearMarks(){}, clearMeasures(){}, clearResourceTimings(){},
getEntries(){return [];}, getEntriesByName(){return [];}, getEntriesByType(){return [];},
@@ -4292,7 +4503,7 @@ globalThis.performance = globalThis.performance || {
timing: { navigationStart: 0, domContentLoadedEventEnd: 0, loadEventEnd: 0 },
navigation: { type: 0, redirectCount: 0 },
memory: {
- jsHeapSizeLimit: 2172649472,
+ jsHeapSizeLimit: 4294705152,
totalJSHeapSize: 19321856,
usedJSHeapSize: 16781520,
},
@@ -4345,7 +4556,11 @@ Object.defineProperty(Document.prototype, 'fonts', {
},
configurable: true,
});
-globalThis.crypto = globalThis.crypto || { getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } };
+globalThis.Crypto = class Crypto {
+ getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); }
+};
+globalThis.crypto = globalThis.crypto || new globalThis.Crypto();
globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v)));
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
@@ -4505,8 +4720,7 @@ globalThis.HTMLLabelElement = Element;
globalThis.HTMLTableElement = Element;
globalThis.HTMLIFrameElement = Element;
globalThis.HTMLCanvasElement = Element;
-globalThis.HTMLVideoElement = Element;
-globalThis.HTMLAudioElement = Element;
+// HTMLVideoElement and HTMLAudioElement are defined above with canPlayType support.
globalThis.HTMLScriptElement = Element;
globalThis.HTMLStyleElement = Element;
globalThis.HTMLLinkElement = Element;
@@ -4780,8 +4994,20 @@ globalThis.Range = class Range {
insertNode(node) { if (node && this._sc && this._sc.insertBefore) { const kids = this._sc.childNodes; this._sc.insertBefore(node, kids[this._so] || null); } }
surroundContents(node) { this.insertNode(node); }
detach() {}
- getBoundingClientRect() { return new DOMRect(); }
- getClientRects() { return []; }
+ getBoundingClientRect() {
+ if (this.collapsed) return new DOMRect();
+ let cac = this.commonAncestorContainer;
+ while (cac && cac.nodeType !== 1 && cac.nodeType !== 9) cac = cac.parentNode;
+ if (cac && cac.getBoundingClientRect) {
+ const r = cac.getBoundingClientRect();
+ return new DOMRect(r.x, r.y, r.width, r.height);
+ }
+ return new DOMRect();
+ }
+ getClientRects() {
+ if (this.collapsed) return new DOMRectList([]);
+ return new DOMRectList([this.getBoundingClientRect()]);
+ }
static get START_TO_START() { return 0; }
static get START_TO_END() { return 1; }
static get END_TO_END() { return 2; }
@@ -4871,6 +5097,7 @@ _markNative(globalThis.Selection);
Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith,
HTMLFormElement.prototype.reset,
Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob,
+ Element.prototype.getBBox,
Node.prototype.appendChild, Node.prototype.removeChild,
Node.prototype.replaceChild, Node.prototype.insertBefore,
Node.prototype.contains, Node.prototype.hasChildNodes, Node.prototype.cloneNode,
@@ -5061,13 +5288,84 @@ class _IframeWindow {
blur() {}
}
+// Encode an RGBA pixel buffer into a valid PNG data URL.
+// Uses stored-block DEFLATE (no compression) wrapped in zlib.
+// This produces a larger file than a real browser but the hash is unique
+// per session (from _fpNoise) and valid, so it does not match the known
+// headless stub.
+function _encodePNG(w, h, rgba) {
+ // RGB scanlines: filter byte (0) + 3 bytes per pixel
+ var rowLen = 1 + w * 3;
+ var raw = new Uint8Array(h * rowLen);
+ for (var y = 0; y < h; y++) {
+ var base = y * rowLen;
+ raw[base] = 0;
+ for (var x = 0; x < w; x++) {
+ var s = (y * w + x) << 2, d = base + 1 + x * 3;
+ raw[d] = rgba[s]; raw[d+1] = rgba[s+1]; raw[d+2] = rgba[s+2];
+ }
+ }
+ // Adler32 of raw
+ var s1 = 1, s2 = 0, M = 65521;
+ for (var i = 0; i < raw.length; i++) { s1 = (s1 + raw[i]) % M; s2 = (s2 + s1) % M; }
+ var adler = ((s2 << 16) | s1) >>> 0;
+ // Stored DEFLATE blocks (zlib level 0)
+ var MAXB = 65535, nb = Math.ceil(raw.length / MAXB) || 1;
+ var dlen = 2 + nb * 5 + raw.length + 4;
+ var def = new Uint8Array(dlen), dp = 0;
+ def[dp++] = 0x78; def[dp++] = 0x01;
+ for (var bi = 0; bi < nb; bi++) {
+ var bs = bi * MAXB, be = Math.min(raw.length, bs + MAXB), bl = be - bs;
+ def[dp++] = bi === nb-1 ? 1 : 0;
+ def[dp++] = bl&0xff; def[dp++] = (bl>>8)&0xff;
+ def[dp++] = (~bl)&0xff; def[dp++] = (~bl>>8)&0xff;
+ def.set(raw.subarray(bs, be), dp); dp += bl;
+ }
+ def[dp++]=(adler>>24)&0xff; def[dp++]=(adler>>16)&0xff; def[dp++]=(adler>>8)&0xff; def[dp]=adler&0xff;
+ // CRC32 (lazy table)
+ if (!_encodePNG._t) {
+ var t = new Uint32Array(256);
+ for (var n = 0; n < 256; n++) { var c = n; for (var k=0;k<8;k++) c=c&1?0xEDB88320^(c>>>1):(c>>>1); t[n]=c; }
+ _encodePNG._t = t;
+ }
+ var T = _encodePNG._t;
+ function crc32(a, st, ln) { var c=0xFFFFFFFF; for(var i=st,e=st+ln;i>>8); return (c^0xFFFFFFFF)>>>0; }
+ function putChunk(out, off, type, data) {
+ var dl = data.length;
+ out[off]=(dl>>24)&0xff; out[off+1]=(dl>>16)&0xff; out[off+2]=(dl>>8)&0xff; out[off+3]=dl&0xff;
+ out[off+4]=type.charCodeAt(0); out[off+5]=type.charCodeAt(1); out[off+6]=type.charCodeAt(2); out[off+7]=type.charCodeAt(3);
+ out.set(data, off+8);
+ var cr = crc32(out, off+4, 4+dl);
+ out[off+8+dl]=(cr>>24)&0xff; out[off+9+dl]=(cr>>16)&0xff; out[off+10+dl]=(cr>>8)&0xff; out[off+11+dl]=cr&0xff;
+ return off+12+dl;
+ }
+ var ihd = new Uint8Array(13);
+ ihd[0]=(w>>24)&0xff; ihd[1]=(w>>16)&0xff; ihd[2]=(w>>8)&0xff; ihd[3]=w&0xff;
+ ihd[4]=(h>>24)&0xff; ihd[5]=(h>>16)&0xff; ihd[6]=(h>>8)&0xff; ihd[7]=h&0xff;
+ ihd[8]=8; ihd[9]=2; // 8-bit RGB
+ var png = new Uint8Array(8 + 25 + (12+dlen) + 12);
+ png.set([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]);
+ var p = 8;
+ p = putChunk(png, p, 'IHDR', ihd);
+ p = putChunk(png, p, 'IDAT', def);
+ putChunk(png, p, 'IEND', new Uint8Array(0));
+ // Base64 encode
+ var C = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ var b64 = 'data:image/png;base64,';
+ for (var i = 0; i < png.length; i += 3) {
+ var a=png[i], b=i+1>2] + C[((a&3)<<4)|(b>>4)] + (i+1>6)]:'=') + (i+2= this._w || y < 0 || y >= this._h) return;
const idx = (y * this._w + x) * 4;
const alpha = (a / 255) * this.globalAlpha;
- this._buf[idx+0] = Math.round(r * alpha + this._buf[idx+0] * (1 - alpha));
- this._buf[idx+1] = Math.round(g * alpha + this._buf[idx+1] * (1 - alpha));
- this._buf[idx+2] = Math.round(b * alpha + this._buf[idx+2] * (1 - alpha));
- this._buf[idx+3] = Math.min(255, Math.round(a * alpha + this._buf[idx+3] * (1 - alpha)));
+ if (this.globalCompositeOperation === 'multiply') {
+ this._buf[idx+0] = Math.round((r/255) * (this._buf[idx+0]/255) * 255);
+ this._buf[idx+1] = Math.round((g/255) * (this._buf[idx+1]/255) * 255);
+ this._buf[idx+2] = Math.round((b/255) * (this._buf[idx+2]/255) * 255);
+ this._buf[idx+3] = Math.min(255, this._buf[idx+3] + Math.round(a * alpha));
+ } else {
+ this._buf[idx+0] = Math.round(r * alpha + this._buf[idx+0] * (1 - alpha));
+ this._buf[idx+1] = Math.round(g * alpha + this._buf[idx+1] * (1 - alpha));
+ this._buf[idx+2] = Math.round(b * alpha + this._buf[idx+2] * (1 - alpha));
+ this._buf[idx+3] = Math.min(255, Math.round(a * alpha + this._buf[idx+3] * (1 - alpha)));
+ }
}
fillRect(x, y, w, h) {
const [r,g,b,a] = this._parseColor(this.fillStyle);
@@ -5227,7 +5532,22 @@ class _Canvas2D {
arc(x, y, r, s, e) { if (this._path) this._path.push({t:'A',x,y,r}); }
arcTo() {}
rect(x, y, w, h) { this.fillRect(x, y, w, h); }
- fill() {}
+ fill() {
+ if (!this._path) return;
+ const [r,g,b,a] = this._parseColor(this.fillStyle);
+ for (const seg of this._path) {
+ if (seg.t === 'A') {
+ const cx = Math.round(seg.x), cy = Math.round(seg.y), rad = seg.r;
+ const r2 = rad * rad;
+ for (let py = Math.max(0, cy - rad); py <= Math.min(this._h - 1, cy + rad); py++) {
+ for (let px = Math.max(0, cx - rad); px <= Math.min(this._w - 1, cx + rad); px++) {
+ if ((px-cx)*(px-cx) + (py-cy)*(py-cy) <= r2) this._setPixel(px, py, r, g, b, a);
+ }
+ }
+ }
+ }
+ this._path = [];
+ }
stroke() {}
clip() {}
save() { this._stateStack.push({fillStyle: this.fillStyle, strokeStyle: this.strokeStyle, globalAlpha: this.globalAlpha, font: this.font, lineWidth: this.lineWidth}); }
@@ -5260,6 +5580,13 @@ Element.prototype.getContext = function getContext(type) {
if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {
return {
canvas: this,
+ MAX_VIEWPORT_DIMS: 0x0D33,
+ MAX_TEXTURE_SIZE: 0x0D33,
+ MAX_RENDERBUFFER_SIZE: 0x84E8,
+ MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84EA,
+ MAX_DRAW_BUFFERS_WEBGL: 0x8824,
+ getContextAttributes() { return { alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, powerPreference: "default", premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: true, desynchronized: false }; },
+ uniform2f() {},
getExtension(name) {
if (name === 'WEBGL_debug_renderer_info') return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 };
return null;
@@ -5271,6 +5598,10 @@ Element.prototype.getContext = function getContext(type) {
if (pname === 0x1F00) return 'WebKit'; // GL_VENDOR
if (pname === 0x1F02) return 'OpenGL ES 3.0 (ANGLE)'; // GL_VERSION
if (pname === 0x8B8C) return 'WebGL GLSL ES 3.00 (ANGLE)'; // GL_SHADING_LANGUAGE_VERSION
+ if (pname === undefined) return [0, 0];
+ // Some properties like MAX_VIEWPORT_DIMS return arrays
+ if (pname === 0x0D33) return [8192, 8192];
+ if (pname === 0x8A2A) return [8192, 8192];
return 0;
},
getSupportedExtensions() { return ['WEBGL_debug_renderer_info','EXT_texture_filter_anisotropic','WEBGL_compressed_texture_s3tc','WEBGL_lose_context']; },
@@ -5298,22 +5629,15 @@ Element.prototype.getContext = function getContext(type) {
Element.prototype.toDataURL = function(type) {
if (this._ctx && this._ctx._buf) {
const ctx = this._ctx;
- const w = ctx._w, h = ctx._h, buf = ctx._buf;
- let hash = _fpSeed;
- for (let i = 0; i < buf.length; i += 37) {
- hash = ((hash << 5) - hash + buf[i]) | 0;
- }
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
- let b64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg';
- for (let i = 0; i < 60; i++) {
- hash = ((hash << 5) - hash + i) | 0;
- b64 += chars[(hash >>> 0) % 64];
- }
- return b64 + '==';
+ return _encodePNG(ctx._w, ctx._h, ctx._buf);
}
return _fp('canvasFingerprint');
};
Element.prototype.toBlob = function(cb, type, q) { cb(new Blob([''])); };
+Element.prototype.getBBox = function() { return { x: 0, y: 0, width: 0, height: 0 }; };
+Element.prototype.getComputedTextLength = function() { return 0; };
+Element.prototype.getExtentOfChar = function(ch) { return { x: 0, y: 0, width: 0, height: 0 }; };
+Element.prototype.getSubStringLength = function(ch, len) { return 0; };
_markNative(Element.prototype.getContext);
_markNative(Element.prototype.toDataURL);
@@ -5451,20 +5775,37 @@ if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe !== 'func
_markNative(Document.parseHTMLUnsafe);
}
+globalThis.AudioBuffer = class AudioBuffer {
+ constructor(opts) {
+ var o = (typeof opts === 'object' && opts !== null) ? opts : {};
+ this.numberOfChannels = o.numberOfChannels || 1;
+ this.length = o.length || 0;
+ this.sampleRate = o.sampleRate || 44100;
+ this.duration = this.length / (this.sampleRate || 44100);
+ this._chs = [];
+ for (var c = 0; c < this.numberOfChannels; c++) this._chs.push(new Float32Array(this.length));
+ }
+ getChannelData(c) { return this._chs[c] || this._chs[0] || new Float32Array(0); }
+ copyFromChannel(dst, ch, start) { var s=this._chs[ch]||this._chs[0]; start=start||0; for(var i=0;ih!==fn); }
+ _ap(v, min=-3.4028235e38, max=3.4028235e38) { return { value: v, defaultValue: v, minValue: min, maxValue: max, setValueAtTime(){} }; }
+ createOscillator() { return {context:this,type:'sine',frequency:this._ap(440, -22050, 22050),detune:this._ap(0, -153600, 153600),connect(){},start(){},stop(){},disconnect(){},addEventListener(){},removeEventListener(){}}; }
+ createDynamicsCompressor() { return {context:this,threshold:this._ap(_fp('compThreshold'), -100, 0),knee:this._ap(_fp('compKnee'), 0, 40),ratio:this._ap(_fp('compRatio'), 1, 20),attack:this._ap(0.003, 0, 1),release:this._ap(0.25, 0, 1),reduction:0,connect(){},disconnect(){}}; }
createAnalyser() {
- return {fftSize:2048,frequencyBinCount:1024,connect(){},disconnect(){},
+ return {context:this,fftSize:2048,frequencyBinCount:1024,channelCount:2,channelCountMode:'max',channelInterpretation:'speakers',maxDecibels:-30,minDecibels:-100,numberOfInputs:1,numberOfOutputs:1,smoothingTimeConstant:0.8,connect(){},disconnect(){},
getByteFrequencyData(a){for(let i=0;i 0 ? target / s : 0;
+ for (var i = 0; i < self.length; i++) data[i] *= scale;
+ // Fire oncomplete + 'complete' listeners on next microtask so callers
+ // can register handlers synchronously after startRendering().
+ var p = Promise.resolve().then(function() {
+ var evt = {renderedBuffer: buf, target: self, type: 'complete'};
+ if (typeof self.oncomplete === 'function') {
+ try { self.oncomplete(evt); } catch(e) {}
+ }
+ var listeners = (self._listeners && self._listeners['complete']) || [];
+ for (var i = 0; i < listeners.length; i++) {
+ try { listeners[i](evt); } catch(e) {}
+ }
+ return buf;
+ });
+ return p;
+ }
};
globalThis.webkitAudioContext = globalThis.AudioContext;
@@ -5652,6 +6033,32 @@ if (typeof navigator.credentials === 'undefined') {
navigator.credentials = { get(){return Promise.resolve(null);}, create(){return Promise.resolve(null);}, store(){return Promise.resolve();}, preventSilentAccess(){return Promise.resolve();} };
}
+navigator.mediaCapabilities = {
+ decodingInfo(cfg) {
+ return Promise.resolve({ supported: true, smooth: true, powerEfficient: true, keySystemAccess: null, configuration: cfg });
+ },
+ encodingInfo(cfg) {
+ return Promise.resolve({ supported: true, smooth: true, powerEfficient: true, configuration: cfg });
+ },
+};
+navigator.locks = {
+ request(name, opts, cb) {
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
+ if (typeof cb === 'function') return Promise.resolve(cb({ name, mode: (opts && opts.mode) || 'exclusive' }));
+ return Promise.resolve(null);
+ },
+ query() { return Promise.resolve({ held: [], pending: [] }); },
+};
+navigator.keyboard = {
+ getLayoutMap() { return Promise.resolve(new Map()); },
+ lock() { return Promise.resolve(); },
+ unlock() {},
+};
+navigator.gpu = { requestAdapter() { return Promise.resolve(null); } };
+navigator.wakeLock = { request() { return Promise.reject(new DOMException('Not allowed', 'NotAllowedError')); } };
+navigator.share = function(data) { return Promise.reject(new DOMException('Not allowed', 'NotAllowedError')); };
+navigator.canShare = function() { return false; };
+
globalThis.opener = null;
globalThis.Worker = class Worker {
@@ -5662,18 +6069,75 @@ globalThis.Worker = class Worker {
this._listeners = {};
const worker = this;
- if (typeof url === 'string' && (url.startsWith('blob:') || url.startsWith('http'))) {
- const blobContent = globalThis.__blobStore?.[url];
- if (blobContent) {
- this._code = blobContent;
- } else {
- (async () => {
- try {
- const resp = await fetch(url);
- worker._code = await resp.text();
- } catch(e) { if (worker.onerror) worker.onerror(e); }
- })();
+ let resolvedUrl = url;
+ if (typeof url === 'string') {
+ const blob = globalThis.__blobStore?.[url];
+ if (blob) {
+ worker._code = blob;
+ // Auto-start on next tick so caller can set onmessage first.
+ setTimeout(() => worker._autoRun(), 0);
+ return;
}
+ // Resolve relative URLs against the current page.
+ if (!url.startsWith('http') && !url.startsWith('blob:') && !url.startsWith('data:')) {
+ try { resolvedUrl = new URL(url, globalThis.location?.href || '').href; } catch(e) {}
+ }
+ (async () => {
+ try {
+ const resp = await fetch(resolvedUrl);
+ worker._code = await resp.text();
+ if (!worker._terminated) worker._autoRun();
+ } catch(e) { if (worker.onerror) worker.onerror(e); }
+ })();
+ }
+ }
+ _makeScope() {
+ const worker = this;
+ // WorkerGlobalScope defined + no document property → IS_WORKER_SCOPE = true in creepjs
+ const scope = {
+ WorkerGlobalScope: function WorkerGlobalScope() {},
+ DedicatedWorkerGlobalScope: function DedicatedWorkerGlobalScope() {},
+ postMessage: (msg) => {
+ if (worker._terminated) return;
+ const evt = { data: msg };
+ if (worker.onmessage) worker.onmessage(evt);
+ const ls = worker._listeners['message'] || [];
+ for (const h of ls) h(evt);
+ },
+ addEventListener: (type, fn) => {
+ if (!scope._ev) scope._ev = {};
+ if (!scope._ev[type]) scope._ev[type] = [];
+ scope._ev[type].push(fn);
+ },
+ close: () => { worker._terminated = true; },
+ crypto: globalThis.crypto,
+ Crypto: globalThis.Crypto,
+ TextEncoder: globalThis.TextEncoder,
+ TextDecoder: globalThis.TextDecoder,
+ atob: globalThis.atob,
+ btoa: globalThis.btoa,
+ setTimeout: globalThis.setTimeout,
+ setInterval: globalThis.setInterval,
+ clearTimeout: globalThis.clearTimeout,
+ clearInterval: globalThis.clearInterval,
+ fetch: globalThis.fetch,
+ console: globalThis.console,
+ performance: globalThis.performance,
+ location: globalThis.location,
+ };
+ scope.self = scope;
+ return scope;
+ }
+ _autoRun() {
+ if (this._terminated || !this._code) return;
+ const worker = this;
+ const scope = worker._makeScope();
+ try {
+ const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code);
+ fn(scope, scope.postMessage, scope.addEventListener, scope.close);
+ } catch(e) {
+ console.error('Worker error:', e.message);
+ if (worker.onerror) worker.onerror(e);
}
}
postMessage(data) {
@@ -5681,32 +6145,13 @@ globalThis.Worker = class Worker {
const worker = this;
setTimeout(() => {
if (worker._terminated || !worker._code) return;
+ const scope = worker._makeScope();
try {
- const workerSelf = {
- onmessage: null,
- postMessage: (msg) => {
- const evt = { data: msg };
- if (worker.onmessage) worker.onmessage(evt);
- const handlers = worker._listeners['message'] || [];
- for (const h of handlers) h(evt);
- },
- addEventListener: (type, fn) => { workerSelf['on' + type] = fn; },
- close: () => { worker._terminated = true; },
- crypto: globalThis.crypto,
- TextEncoder: globalThis.TextEncoder,
- TextDecoder: globalThis.TextDecoder,
- atob: globalThis.atob,
- btoa: globalThis.btoa,
- setTimeout: globalThis.setTimeout,
- setInterval: globalThis.setInterval,
- clearTimeout: globalThis.clearTimeout,
- clearInterval: globalThis.clearInterval,
- fetch: globalThis.fetch,
- console: globalThis.console,
- };
const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code);
- fn(workerSelf, workerSelf.postMessage, workerSelf.addEventListener, workerSelf.close);
- if (workerSelf.onmessage) workerSelf.onmessage({ data });
+ fn(scope, scope.postMessage, scope.addEventListener, scope.close);
+ const evs = (scope._ev && scope._ev['message']) || [];
+ if (evs.length) { for (const h of evs) h({ data }); }
+ else if (scope.onmessage) scope.onmessage({ data });
} catch(e) {
console.error('Worker error:', e.message);
if (worker.onerror) worker.onerror(e);
@@ -5740,16 +6185,16 @@ URL.revokeObjectURL = function(url) {
globalThis.scrollTo = function(x, y) {};
globalThis.scrollBy = function(x, y) {};
globalThis.scroll = function(x, y) {};
-globalThis.focus = function() {};
-globalThis.blur = function() {};
-globalThis.print = function() {};
-globalThis.alert = function() {};
-globalThis.confirm = function() { return true; };
-globalThis.prompt = function() { return null; };
-globalThis.open = function() { return null; };
-globalThis.close = function() {};
-globalThis.stop = function() {};
-globalThis.postMessage = function() {};
+globalThis.focus = function() {}; _markNative(globalThis.focus);
+globalThis.blur = function() {}; _markNative(globalThis.blur);
+globalThis.print = function() {}; _markNative(globalThis.print);
+globalThis.alert = function() {}; _markNative(globalThis.alert);
+globalThis.confirm = function() { return true; }; _markNative(globalThis.confirm);
+globalThis.prompt = function() { return null; }; _markNative(globalThis.prompt);
+globalThis.open = function() { return null; }; _markNative(globalThis.open);
+globalThis.close = function() {}; _markNative(globalThis.close);
+globalThis.stop = function() {}; _markNative(globalThis.stop);
+globalThis.postMessage = function() {}; _markNative(globalThis.postMessage);
globalThis.requestIdleCallback = globalThis.requestIdleCallback || function(cb) { return setTimeout(cb, 0); };
globalThis.cancelIdleCallback = globalThis.cancelIdleCallback || function(id) { clearTimeout(id); };
if (typeof ReadableStream === 'undefined') {
@@ -5851,6 +6296,20 @@ if (typeof DOMRect === 'undefined') {
static fromRect(r={}) { return new DOMRect(r.x,r.y,r.width,r.height); }
};
}
+
+if (typeof DOMRectList === 'undefined') {
+ globalThis.DOMRectList = class DOMRectList {
+ constructor(arr=[]) {
+ this.length = arr.length;
+ for (let i = 0; i < arr.length; i++) this[i] = arr[i];
+ }
+ item(i) { return this[i] || null; }
+ [Symbol.iterator]() {
+ let i = 0, self = this;
+ return { next() { const done = i >= self.length; return { value: done ? undefined : self[i++], done }; } };
+ }
+ };
+}
if (typeof DOMPoint === 'undefined') {
globalThis.DOMPoint = class DOMPoint {
constructor(x=0,y=0,z=0,w=1) { this.x=x;this.y=y;this.z=z;this.w=w; }
@@ -5998,6 +6457,13 @@ if (typeof EventSource === 'undefined') {
if (typeof WebSocket === 'undefined') {
globalThis.WebSocket = class WebSocket {
constructor(url, protocols) {
+ // Validate URL scheme per spec — Chrome throws SyntaxError for non-ws/wss URLs
+ if (typeof url !== 'string' || !/^wss?:\/\//i.test(url)) {
+ throw new DOMException(
+ "Failed to construct 'WebSocket': The URL '" + url + "' is invalid.",
+ 'SyntaxError'
+ );
+ }
this.url = url;
this.readyState = 0; // CONNECTING
this.bufferedAmount = 0;
@@ -6107,10 +6573,44 @@ if (typeof Range === 'undefined') {
setStart(n,o){this.startContainer=n;this.startOffset=o;} setEnd(n,o){this.endContainer=n;this.endOffset=o;}
collapse(){} selectNode(){} selectNodeContents(){} cloneContents(){return document?.createDocumentFragment();}
deleteContents(){} insertNode(){} getBoundingClientRect(){return new DOMRect();}
- getClientRects(){return [];} cloneRange(){return new Range();} toString(){return '';}
+ getClientRects(){return new DOMRectList([]);} cloneRange(){return new Range();} toString(){return '';}
};
}
+if (typeof FontFace === 'undefined') {
+ globalThis.FontFace = class FontFace {
+ constructor(family, source, descriptors={}) {
+ this.family = family;
+ this.style = descriptors.style || 'normal';
+ this.weight = descriptors.weight || 'normal';
+ this.stretch = descriptors.stretch || 'normal';
+ this.unicodeRange = descriptors.unicodeRange || 'U+0-10FFFF';
+ this.variant = descriptors.variant || 'normal';
+ this.featureSettings = descriptors.featureSettings || 'normal';
+ this.status = 'unloaded';
+ }
+ load() { this.status = 'loaded'; return Promise.resolve(this); }
+ };
+ globalThis.FontFaceSet = class FontFaceSet extends EventTarget {
+ constructor() { super(); this.status = 'loaded'; this.ready = Promise.resolve(this); }
+ add() { return this; }
+ check() { return true; }
+ clear() {}
+ delete() { return false; }
+ load() { return Promise.resolve([]); }
+ forEach() {}
+ has() { return false; }
+ [Symbol.iterator]() { return [][Symbol.iterator](); }
+ };
+ Object.defineProperty(Document.prototype, 'fonts', {
+ get() {
+ if (!this._fonts) this._fonts = new FontFaceSet();
+ return this._fonts;
+ },
+ configurable: true
+ });
+}
+
if (typeof SharedWorker === 'undefined') {
globalThis.SharedWorker = class SharedWorker {
constructor() { this.port = { postMessage(){}, onmessage:null, start(){}, close(){}, addEventListener(){}, removeEventListener(){} }; this.onerror = null; }
@@ -6190,26 +6690,51 @@ globalThis.__obscura_init = function() {
const scr = _fp('screen');
const sw = scr[0], sh = scr[1];
- globalThis.screen = { width:sw, height:sh, availWidth:sw, availHeight:sh-40, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} };
+ globalThis.screen = new Screen(sw, sh);
globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} };
globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1;
globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80;
- globalThis.outerWidth = sw; globalThis.outerHeight = sh;
+ globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40;
- var hwValues = [2, 4, 6, 8, 12, 16];
+ var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [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];
+ var memValues = globalThis.__obscura_stealth ? [4, 8] : [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.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 };
+ var _totalHeap = 15000000 + Math.floor(_fpRand(620) * 85000000);
globalThis.performance.memory = {
- jsHeapSizeLimit: 2172649472,
- totalJSHeapSize: 15000000 + Math.floor(_fpRand(620) * 85000000),
- usedJSHeapSize: 8000000 + Math.floor(_fpRand(621) * 42000000),
+ jsHeapSizeLimit: 4294705152,
+ totalJSHeapSize: _totalHeap,
+ usedJSHeapSize: Math.floor(_totalHeap * (0.3 + _fpRand(621) * 0.5)),
};
- globalThis.Notification.permission = _fpRand(630) > 0.5 ? "granted" : "default";
+ globalThis.Notification.permission = "default";
+
+ if (globalThis.__obscura_stealth) {
+ var _stealthBrands = [
+ {brand: "Not:A-Brand", version: "99"},
+ {brand: "Google Chrome", version: "145"},
+ {brand: "Chromium", version: "145"},
+ ];
+ globalThis.navigator.userAgentData.brands = _stealthBrands;
+ globalThis.navigator.userAgentData.getHighEntropyValues = function(hints) {
+ return Promise.resolve({
+ architecture: "x86", bitness: "64",
+ brands: _stealthBrands,
+ fullVersionList: [
+ {brand:"Not:A-Brand",version:"99.0.0.0"},
+ {brand:"Google Chrome",version:"145.0.0.0"},
+ {brand:"Chromium",version:"145.0.0.0"},
+ ],
+ mobile: false, model: "",
+ platform: "Windows",
+ platformVersion: "10.0.0",
+ uaFullVersion: "145.0.0.0",
+ });
+ };
+ }
// Hide internals (_*, obscura, Obscura). The set of keys is static at
// snapshot-build time, so we precompute it ONCE below (after this
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index cd015eb..7fb09f9 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -1,5 +1,5 @@
use std::cell::RefCell;
-use std::collections::HashMap;
+use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use std::sync::Arc;
@@ -38,6 +38,12 @@ pub struct InterceptedRequest {
pub resolver: tokio::sync::oneshot::Sender,
}
+#[derive(Debug, Clone)]
+pub struct StoredNetworkResponseBody {
+ pub body: String,
+ pub base64_encoded: bool,
+}
+
pub struct ObscuraState {
pub dom: Option,
pub url: String,
@@ -57,6 +63,9 @@ pub struct ObscuraState {
// `op_binding_called` op. Drained by the CDP layer after each dispatch
// and emitted as `Runtime.bindingCalled` events.
pub pending_binding_calls: Vec<(String, String)>,
+ pub network_response_bodies: HashMap,
+ pub network_response_body_order: VecDeque,
+ pub network_response_body_counter: u64,
}
impl ObscuraState {
@@ -74,10 +83,27 @@ impl ObscuraState {
intercept_counter: 0,
intercept_enabled: false,
pending_binding_calls: Vec::new(),
+ network_response_bodies: HashMap::new(),
+ network_response_body_order: VecDeque::new(),
+ network_response_body_counter: 0,
}
}
}
+fn response_body_entry_limit() -> usize {
+ std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(128)
+}
+
+fn response_body_byte_limit() -> usize {
+ std::env::var("OBSCURA_NETWORK_BODY_BUFFER_BYTES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(2 * 1024 * 1024)
+}
+
pub type SharedState = Rc>;
#[op2]
@@ -137,7 +163,21 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
"null".into()
}
"get_element_by_id" => {
- dom.get_element_by_id(&arg1).map(|id| id.index().to_string()).unwrap_or("-1".into())
+ // Verify the indexed node is in the live document. The id_index is best-effort:
+ // it only registers nodes at creation time and doesn't update on reparent, so
+ // it can point to a detached clone while the live node is elsewhere in the tree.
+ let doc = dom.document();
+ let nid = dom.get_element_by_id(&arg1);
+ let live = nid.filter(|&n| dom.ancestors(n).contains(&doc));
+ match live {
+ Some(n) => n.index().to_string(),
+ None => {
+ // Fall back to full scan for the live document.
+ let sel = format!("[id=\"{}\"]", arg1.replace('\\', "\\\\").replace('"', "\\\""));
+ dom.query_selector(&sel).ok().flatten()
+ .map(|id| id.index().to_string()).unwrap_or("-1".into())
+ }
+ }
}
"query_selector" => {
dom.query_selector(&arg1).ok().flatten().map(|id| id.index().to_string()).unwrap_or("-1".into())
@@ -235,19 +275,22 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
serde_json::to_string(&dom.outer_html(NodeId::new(nid))).unwrap_or("\"\"".into())
}
"append_child" => {
- let parent = arg1.parse::().unwrap_or(0);
- let child = arg2.parse::().unwrap_or(0);
+ // Reject if either nid failed to parse (was "undefined"/empty) — those
+ // default to 0 which is the document root, and silently operating on it
+ // corrupts the tree. Require both args to be valid positive integers.
+ let parent = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
+ let child = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.append_child(NodeId::new(parent), NodeId::new(child));
"true".into()
}
"remove_child" => {
- let child = arg1.parse::().unwrap_or(0);
+ let child = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.remove_child(NodeId::new(child));
"true".into()
}
"insert_before" => {
- let new_node = arg1.parse::().unwrap_or(0);
- let ref_node = arg2.parse::().unwrap_or(0);
+ let new_node = match arg1.parse::() { Ok(n) => n, Err(_) => return "false".into() };
+ let ref_node = match arg2.parse::() { Ok(n) => n, Err(_) => return "false".into() };
dom.insert_before(NodeId::new(ref_node), NodeId::new(new_node));
"true".into()
}
@@ -261,7 +304,12 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
"true".into()
}
"set_inner_html" => {
- let nid = arg1.parse::().unwrap_or(0);
+ let nid = match arg1.parse::() {
+ Ok(n) if n > 0 => n,
+ // nid=0 is the document root; never allow innerHTML to clear it.
+ // nid parse failure (e.g. "undefined") also falls here.
+ _ => return "false".into(),
+ };
let target = NodeId::new(nid);
let children = dom.children(target);
for child in children {
@@ -850,6 +898,31 @@ async fn op_fetch_url(
.map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?;
let resp_body = String::from_utf8_lossy(&resp_bytes).to_string();
let resp_body_base64 = BASE64.encode(&resp_bytes);
+ let response_request_id = {
+ let state_borrow = state.borrow();
+ let gs = state_borrow.borrow::().clone();
+ let mut gs = gs.borrow_mut();
+ gs.network_response_body_counter += 1;
+ let request_id = format!("fetch-{}", gs.network_response_body_counter);
+ let max_entries = response_body_entry_limit();
+ let max_bytes = response_body_byte_limit();
+ if max_entries > 0 && max_bytes > 0 && resp_bytes.len() <= max_bytes {
+ gs.network_response_bodies.insert(
+ request_id.clone(),
+ StoredNetworkResponseBody {
+ body: resp_body.clone(),
+ base64_encoded: false,
+ },
+ );
+ gs.network_response_body_order.push_back(request_id.clone());
+ while gs.network_response_body_order.len() > max_entries {
+ if let Some(oldest) = gs.network_response_body_order.pop_front() {
+ gs.network_response_bodies.remove(&oldest);
+ }
+ }
+ }
+ request_id
+ };
tracing::debug!("op_fetch_url completed: {} {} ({} bytes)", method, url, resp_body.len());
@@ -857,6 +930,7 @@ async fn op_fetch_url(
"status": status,
"body": resp_body,
"bodyBase64": resp_body_base64,
+ "requestId": response_request_id,
"url": url,
"headers": resp_headers,
})
diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs
index 92908d7..e0bdbcc 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -10,7 +10,7 @@ use obscura_dom::DomTree;
pub use deno_core::v8::IsolateHandle;
use crate::module_loader::ObscuraModuleLoader;
-use crate::ops::{build_extension, ObscuraState};
+use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
@@ -130,7 +130,7 @@ impl ObscuraJsRuntime {
runtime
.execute_script(
"",
- "globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; globalThis.__obscura_init();".to_string(),
+ "globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0;".to_string(),
)
.expect("init should not fail");
@@ -184,6 +184,16 @@ impl ObscuraJsRuntime {
std::mem::take(&mut self.state.borrow_mut().pending_binding_calls)
}
+ pub fn get_network_response_body(&self, request_id: &str) -> Option {
+ self.state.borrow().network_response_bodies.get(request_id).cloned()
+ }
+
+ pub fn clear_network_response_bodies(&self) {
+ let mut state = self.state.borrow_mut();
+ state.network_response_bodies.clear();
+ state.network_response_body_order.clear();
+ }
+
/// Wire up the interception channel without enabling interception.
/// Use set_intercept_enabled separately. The two were entangled before
/// and every navigation auto-enabled interception, which made
@@ -220,6 +230,22 @@ impl ObscuraJsRuntime {
);
}
+ pub fn set_stealth(&mut self, enabled: bool) {
+ let _ = self.runtime.execute_script(
+ "",
+ format!("globalThis.__obscura_stealth = {};", enabled),
+ );
+ }
+
+ /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
+ /// have been set. Must be called once per page setup, after all set_* methods.
+ pub fn run_page_init(&mut self) {
+ let _ = self.runtime.execute_script(
+ "",
+ "globalThis.__obscura_init();".to_string(),
+ );
+ }
+
/// Override the coordinates the navigator.geolocation shim reports. The
/// values are injected as numeric globals the bootstrap reads; when unset it
/// keeps the built-in default. Callers validate the range before calling.
@@ -2452,6 +2478,21 @@ mod tests {
);
}
+ #[test]
+ fn console_log_error_does_not_trigger_prepare_stack_trace() {
+ let mut rt = setup_runtime("");
+ let result = rt.evaluate(r#"
+ let called = false;
+ const saved = Error.prepareStackTrace;
+ Error.prepareStackTrace = function() { called = true; return saved; };
+ const e = new Error("test");
+ console.log(e);
+ Error.prepareStackTrace = saved;
+ return called;
+ "#).unwrap();
+ assert_eq!(result, serde_json::json!(false));
+ }
+
#[test]
fn element_aria_reflection_setters_write_through() {
let mut rt = setup_runtime(r#""#);
diff --git a/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml
index 1b13bdf..5246806 100644
--- a/crates/obscura-net/Cargo.toml
+++ b/crates/obscura-net/Cargo.toml
@@ -23,14 +23,14 @@ tempfile = "3"
# Emulation builder reshuffled). A caret requirement let a fresh resolve pull a
# newer, incompatible rc and broke `cargo build --features stealth` (issue #234).
# Pin exact versions so the build always resolves to the API wreq_client.rs targets.
-wreq-util = { version = "=3.0.0-rc.10", optional = true }
+wreq-util = { version = "=3.0.0-rc.12", optional = true }
# `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_*`
# symbols at link time (see issue #39).
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
-wreq = { version = "=6.0.0-rc.28", optional = true, features = ["prefix-symbols", "socks"] }
+wreq = { version = "=6.0.0-rc.29", optional = true, features = ["prefix-symbols", "socks"] }
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
-wreq = { version = "=6.0.0-rc.28", optional = true, features = ["socks"] }
+wreq = { version = "=6.0.0-rc.29", optional = true, features = ["socks"] }
diff --git a/crates/obscura-net/src/wreq_client.rs b/crates/obscura-net/src/wreq_client.rs
index e2ef668..e0405d4 100644
--- a/crates/obscura-net/src/wreq_client.rs
+++ b/crates/obscura-net/src/wreq_client.rs
@@ -36,21 +36,13 @@ impl StealthHttpClient {
}
pub fn with_proxy(cookie_jar: Arc, proxy_url: Option<&str>) -> Self {
- // Issue #184: `set_default_paths()` reads OpenSSL's compile-time CA
- // paths, which only resolve on Linux. On Windows the store ends up
- // empty and every TLS handshake fails with CERTIFICATE_VERIFY_FAILED.
- // `CertStore::default()` uses wreq's bundled Mozilla roots
- // (`webpki-root-certs`), which works the same on every platform.
- let cert_store = wreq::tls::CertStore::default();
-
- let emulation_opts = wreq_util::EmulationOption::builder()
- .emulation(wreq_util::Emulation::Chrome145)
- .emulation_os(wreq_util::EmulationOS::Linux)
+ let emulation_opts = wreq_util::Emulation::builder()
+ .profile(wreq_util::Profile::Chrome145)
+ .platform(wreq_util::Platform::Linux)
.build();
let mut builder = wreq::Client::builder()
.emulation(emulation_opts)
- .cert_store(cert_store)
.timeout(Duration::from_secs(30))
.redirect(wreq::redirect::Policy::none());
diff --git a/docs/CLI-reference.md b/docs/CLI-reference.md
index 592c4b9..fee7a51 100644
--- a/docs/CLI-reference.md
+++ b/docs/CLI-reference.md
@@ -61,6 +61,7 @@ Run the CDP server. Puppeteer and Playwright connect over WebSocket.
--allow-file-access Permit CDP clients to navigate to file:// URLs
--storage-dir Persistent cookies and localStorage
--allow-private-network Permit loopback / RFC1918 / link-local
+-q, --quiet Suppress info logging
-v, --verbose Enable info logging
```
@@ -95,6 +96,7 @@ Run obscura as an MCP server.
```
--http HTTP transport instead of stdio
+ --host HTTP bind host (default 127.0.0.1)
--port HTTP port (default 3000)
--proxy HTTP or SOCKS5 proxy
--user-agent Override the User-Agent
@@ -103,4 +105,6 @@ Run obscura as an MCP server.
-v, --verbose Enable info logging
```
+`--host` only applies with `--http`. The default `127.0.0.1` keeps the server loopback-only; set `0.0.0.0` to bind all interfaces (for example a Docker Compose sidecar) and pair it with `OBSCURA_MCP_ALLOWED_ORIGINS`.
+
Default transport is stdio. See [Use the MCP server](Use-the-MCP-server.md).
diff --git a/docs/Configure-stealth-and-proxies.md b/docs/Configure-stealth-and-proxies.md
index 50b13dd..e3b3e07 100644
--- a/docs/Configure-stealth-and-proxies.md
+++ b/docs/Configure-stealth-and-proxies.md
@@ -60,6 +60,31 @@ obscura serve --user-agent "Mozilla/5.0 (...) ..."
Default UA matches a recent Chrome on the build platform.
+## Browser profile, timezone, and geolocation
+
+The engine presents one of a built-in pool of realistic browser profiles (a mix of Windows and macOS, recent Chrome versions). Each profile keeps `navigator.platform`, `navigator.userAgentData` (platform and platform version), the UA string, and the WebGL/GPU renderer internally consistent, so the surfaces a site fingerprints agree with each other. Windows profiles report ANGLE Direct3D11 renderers, macOS profiles report ANGLE Metal renderers.
+
+A single stable profile is used by default. One IP cycling through different identities is itself a signal, so rotation is opt-in:
+
+```bash
+OBSCURA_PROFILE=2 obscura serve # pin a specific profile by index
+OBSCURA_ROTATE_PROFILE=1 obscura serve # random profile per browser context
+```
+
+Timezone is driven by the process zone so `Date` (`getTimezoneOffset`, `toString`) and `Intl.DateTimeFormat` report the same region. Default is `Europe/Berlin`; set it to match the exit IP:
+
+```bash
+OBSCURA_TIMEZONE=America/New_York obscura serve
+```
+
+`navigator.geolocation` reports configurable coordinates. Set them as `lat,lon` and keep them consistent with the timezone and proxy region:
+
+```bash
+OBSCURA_GEOLOCATION="40.7128,-74.0060" obscura serve
+```
+
+Keep these aligned. A rotated or mismatched profile carries no matching TLS or timezone fingerprint, so when you pin a proxy region or TLS fingerprint, leave rotation off and set the timezone and geolocation to the same region. See [Environment variables](Environment-variables.md) for the full list.
+
## Combine
```bash
diff --git a/docs/Connect-Puppeteer-or-Playwright.md b/docs/Connect-Puppeteer-or-Playwright.md
index 1653c02..8d34771 100644
--- a/docs/Connect-Puppeteer-or-Playwright.md
+++ b/docs/Connect-Puppeteer-or-Playwright.md
@@ -72,13 +72,15 @@ await page.goto('https://example.com', { waitUntil: 'load' });
- `page.goto`, `page.reload`, `page.goBack`, `page.goForward`
- `page.evaluate`, `page.evaluateHandle`
-- `page.click`, `page.type`, `page.fill`
+- `page.click`, `page.type`, `page.fill`, `page.focus`
- `page.waitForSelector`, `page.waitForFunction`, `page.waitForNavigation`
- `page.cookies`, `page.setCookie`, `context.cookies`
- `page.setRequestInterception`, block / modify
- `page.exposeFunction`
- `page.content`, `page.title`, `page.url`
+DOM-agent frameworks such as browser-use also connect: obscura implements `DOMSnapshot.captureSnapshot` and `Target.targetInfoChanged` for perception, and `DOM.focus` so a focused field receives `Input.dispatchKeyEvent` keystrokes.
+
## Not supported
- `page.screenshot`: obscura doesn't render pixels.
diff --git a/docs/Environment-variables.md b/docs/Environment-variables.md
index 05ae02e..af50c5e 100644
--- a/docs/Environment-variables.md
+++ b/docs/Environment-variables.md
@@ -2,7 +2,9 @@
### `OBSCURA_ALLOW_PRIVATE_NETWORK`
-Allow fetches to loopback (`127.0.0.0/8`), RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), and link-local (`169.254.0.0/16`) addresses. Off by default to block SSRF.
+Allow fetches to loopback (`127.0.0.0/8`), RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), and link-local (`169.254.0.0/16`, including the `169.254.169.254` cloud-metadata endpoint) addresses. The deny-set also covers the unspecified address (`0.0.0.0` / `::`), IPv6 unique-local (`fc00::/7`), and any IPv4-mapped form of the above. Off by default to block SSRF.
+
+The guard validates at DNS-resolution time as well as on literal hosts, so a public hostname that resolves to a forbidden address is rejected at connect time (DNS-rebinding safe), not just hosts written as raw IPs.
Truthy values: `1`, `true`, `yes`, `on`.
@@ -44,6 +46,52 @@ Default proxy URL used by `obscura-worker` for the parallel `scrape` command whe
OBSCURA_PROXY=http://proxy.example.com:8080 obscura scrape - < urls.txt
```
+## Stealth and identity
+
+These tune the browser identity the engine presents so it stays internally consistent. See [Configure stealth and proxies](Configure-stealth-and-proxies.md) for the full picture.
+
+### `OBSCURA_TIMEZONE`
+
+Pins the process timezone before V8/ICU reads it, so `Date` (`getTimezoneOffset`, `toString`) and `Intl.DateTimeFormat` report one consistent zone. Default `Europe/Berlin`. Set it to match the exit IP's region.
+
+```bash
+OBSCURA_TIMEZONE=America/New_York obscura serve
+```
+
+### `OBSCURA_GEOLOCATION`
+
+Override the coordinates the `navigator.geolocation` shim reports, as `lat,lon`. Without it the shim reports a fixed default. Keep it consistent with `OBSCURA_TIMEZONE` and the proxy region.
+
+```bash
+OBSCURA_GEOLOCATION="40.7128,-74.0060" obscura serve
+```
+
+### `OBSCURA_PROFILE`
+
+Pin a specific browser profile from the built-in pool by index (`0`-based). Each profile keeps `navigator.platform`, `userAgentData`, the UA string, and the GPU renderer internally consistent. Without it a single stable profile is used.
+
+```bash
+OBSCURA_PROFILE=2 obscura serve
+```
+
+### `OBSCURA_ROTATE_PROFILE`
+
+Opt into picking a random profile per browser context instead of the stable default. Leave it off when you pin a TLS fingerprint, proxy region, or timezone, since a rotated profile would no longer match those.
+
+```bash
+OBSCURA_ROTATE_PROFILE=1 obscura serve
+```
+
+## MCP
+
+### `OBSCURA_MCP_ALLOWED_ORIGINS`
+
+Comma-separated `Origin` allowlist for the HTTP MCP transport (`obscura mcp --http`). Off by default, which keeps the permissive behavior. When set, a browser request whose `Origin` is not listed is refused with `403` before it can drive the server; native, non-browser MCP clients (which send no `Origin`) are always allowed. Use it to stop cross-origin pages from reaching a loopback MCP port.
+
+```bash
+OBSCURA_MCP_ALLOWED_ORIGINS="https://app.example.com" obscura mcp --http --host 0.0.0.0
+```
+
## Logging
### `RUST_LOG`
@@ -76,7 +124,7 @@ V8 flags are passed via `--v8-flags`, not environment variables:
obscura serve --v8-flags "--max-old-space-size=2048 --expose-gc"
```
-Default heap is 4 GB on 64-bit systems.
+Defaults are `--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size` on 64-bit systems (a 4 GB old-space ceiling, a capped young generation, and codegen tuned for a smaller footprint to cut RSS). Anything you pass with `--v8-flags` is appended after these, and V8 uses the last value for a repeated flag, so your value wins for that flag while the other defaults stay in effect.
## HTTP proxy environment
diff --git a/docs/Run-in-production-at-scale.md b/docs/Run-in-production-at-scale.md
index 0958cbe..6eaafdf 100644
--- a/docs/Run-in-production-at-scale.md
+++ b/docs/Run-in-production-at-scale.md
@@ -50,13 +50,13 @@ Use one worker per CPU core. Each worker handles its own pool of pages. Sessions
## V8 heap
-Default V8 heap is 4 GB on 64-bit systems. Override:
+Default V8 heap is 4 GB on 64-bit systems. The defaults also cap the young generation (`--max-semi-space-size=4`) and pass `--optimize-for-size` to hold RSS down. Override:
```bash
obscura serve --v8-flags "--max-old-space-size=2048"
```
-Lower for memory-constrained hosts, raise for heavy SPAs.
+Flags you pass are appended after the defaults, and V8 uses the last value for a repeated flag, so your `--max-old-space-size` wins while the memory-tuning defaults stay in effect. Lower for memory-constrained hosts, raise for heavy SPAs.
## Parallel scrape
@@ -120,6 +120,17 @@ Obscura's CDP server has no built-in auth. Anyone who can reach the port can dri
Never bind `0.0.0.0` on a public IP without one of the above.
+## MCP HTTP transport
+
+`obscura mcp --http` binds `127.0.0.1` by default. To reach it from another container, bind with `--host 0.0.0.0` and set an `Origin` allowlist so a browser page cannot drive it cross-origin:
+
+```bash
+OBSCURA_MCP_ALLOWED_ORIGINS="https://app.example.com" \
+ obscura mcp --http --host 0.0.0.0 --port 3000
+```
+
+Request bodies are capped at 16 MiB. Like the CDP server it has no built-in auth, so keep it on an internal network or behind an authenticating proxy. See [Use the MCP server](Use-the-MCP-server.md).
+
## Observability
```bash
diff --git a/docs/Use-the-MCP-server.md b/docs/Use-the-MCP-server.md
index 9769deb..b6af3d0 100644
--- a/docs/Use-the-MCP-server.md
+++ b/docs/Use-the-MCP-server.md
@@ -14,12 +14,31 @@ HTTP (for remote or shared use):
obscura mcp --http --port 3000
```
+The HTTP transport binds `127.0.0.1` by default. Bind all interfaces with `--host` for a container or sidecar deployment:
+
+```bash
+obscura mcp --http --host 0.0.0.0 --port 3000
+```
+
With stealth and proxy:
```bash
obscura mcp --stealth --proxy http://proxy.example.com:8080
```
+## Security
+
+The HTTP transport has no built-in auth, so anyone who can reach the port can drive the browser. Two guards ship for the HTTP transport:
+
+- **Origin allowlist.** Set `OBSCURA_MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed `Origin` values. When set, a browser request from an unlisted origin is refused with `403` before it can drive the server, which blocks a malicious page from POSTing to a loopback MCP port. Native, non-browser clients send no `Origin` and are always allowed. Unset (the default) keeps the permissive behavior.
+- **Body cap.** A single request body is capped at 16 MiB, so an unauthenticated caller cannot force a large allocation with an oversized `Content-Length`.
+
+```bash
+OBSCURA_MCP_ALLOWED_ORIGINS="https://app.example.com" obscura mcp --http --host 0.0.0.0
+```
+
+When you expose the HTTP transport beyond loopback, set the allowlist and put it behind a reverse proxy or network isolation that enforces auth.
+
## Tools exposed
The server keeps a live browser session, so tools operate on the current page rather than taking a URL each call. Navigate first, then read or act.