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.