From dc780d77e409fdcc81d83c0c4932cf4803435662 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:19:02 +0000 Subject: [PATCH 1/8] js: handle SHA-512/224 and SHA-512/256 in crypto.subtle.digest The digest op fell through to SHA-256 for any unrecognized algorithm name, so SHA-512/224 and SHA-512/256 silently returned a SHA-256 hash. Add the two FIPS 180-4 truncated variants, which the AWS WAF challenge proof-of-work (used by booking.com and other CloudFront/WAF sites) requires. Fixes #314. --- crates/obscura-js/src/ops.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 1975b72..99cccd3 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -1067,9 +1067,10 @@ fn op_binding_called(state: &OpState, #[string] name: &str, #[string] payload: & } /// Real WebCrypto `crypto.subtle.digest`. `algorithm` is the SubtleCrypto -/// algorithm name (`SHA-1` / `SHA-256` / `SHA-384` / `SHA-512`); unknown -/// names fall through to SHA-256 to match the previous JS fallback. Returns -/// the raw digest bytes so the JS shim can hand them back as an ArrayBuffer. +/// algorithm name (`SHA-1` / `SHA-256` / `SHA-384` / `SHA-512`, plus the +/// FIPS 180-4 truncated variants `SHA-512/224` and `SHA-512/256`). Other +/// names fall back to SHA-256. Returns the raw digest bytes so the JS shim +/// can hand them back as an ArrayBuffer. #[op2] #[buffer] fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec { @@ -1080,6 +1081,8 @@ fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec "SHA-256" => sha2::Sha256::digest(data).to_vec(), "SHA-384" => sha2::Sha384::digest(data).to_vec(), "SHA-512" => sha2::Sha512::digest(data).to_vec(), + "SHA-512/224" => sha2::Sha512_224::digest(data).to_vec(), + "SHA-512/256" => sha2::Sha512_256::digest(data).to_vec(), _ => sha2::Sha256::digest(data).to_vec(), } } From 4309935abdbbd103a7683c96edecfe56b9aba788 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:19:16 +0000 Subject: [PATCH 2/8] stealth: keep the Chrome 145 identity consistent across TLS, JS, and WebGL Stealth mode forced a Linux navigator UA and platform but reported Windows in userAgentData.getHighEntropyValues and served Windows Direct3D11 WebGL renderers, a combination no real Linux Chrome ever produces and that cross-layer fingerprint checks flag. Present one consistent Windows Chrome 145 identity instead: the wreq emulation uses Platform::Windows, and the navigator surfaces read the UA and platform from shared constants so the HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, and userAgentData all agree. userAgentData brands are now derived from the Chrome major version via Chromium's GREASE algorithm rather than a single hardcoded token, and a Linux WebGL renderer pool is added so a Linux UA can never report a Direct3D11 renderer. --- crates/obscura-browser/src/page.rs | 8 ++- crates/obscura-js/js/bootstrap.js | 85 ++++++++++++++++----------- crates/obscura-net/src/lib.rs | 5 +- crates/obscura-net/src/wreq_client.rs | 15 ++++- 4 files changed, 74 insertions(+), 39 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 51e75ea..44fb44f 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -287,9 +287,11 @@ impl Page { if self.stealth_client.is_some() { rt.set_stealth(true); rt.set_user_agent(obscura_net::STEALTH_USER_AGENT); - // Match platform to the Linux UA so navigator.platform and - // userAgentData.platform are consistent with navigator.userAgent. - rt.set_platform("Linux x86_64", "Linux", ""); + rt.set_platform( + obscura_net::STEALTH_NAVIGATOR_PLATFORM, + obscura_net::STEALTH_UA_PLATFORM, + obscura_net::STEALTH_UA_PLATFORM_VERSION, + ); } else { if let Ok(ua) = self.http_client.user_agent.try_read() { rt.set_user_agent(&ua); diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index c1fb240..a4e2d3a 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -154,7 +154,9 @@ function _fpNoise(x, y, channel) { var _fpCache = null; function _getFp() { if (_fpCache) return _fpCache; - const isMac = (globalThis.__obscura_ua_platform || 'Windows') === 'macOS'; + const _uaPlat = globalThis.__obscura_ua_platform || 'Windows'; + const isMac = _uaPlat === 'macOS'; + const isLinux = _uaPlat === 'Linux'; const gpuPool = isMac ? [ 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)', 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)', @@ -162,6 +164,14 @@ function _getFp() { 'ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version)', 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)', 'ANGLE (Intel Inc., ANGLE Metal Renderer: Intel(R) Iris(TM) Plus Graphics, Unspecified Version)', + ] : isLinux ? [ + 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)', + 'ANGLE (Intel, Mesa Intel(R) Iris(R) Xe Graphics (TGL GT2), OpenGL 4.6)', + 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 770 (RPL-S), OpenGL 4.6)', + 'ANGLE (AMD, AMD Radeon RX 580 (polaris10, LLVM 15.0.7, DRM 3.54, LLVM 15.0.7), OpenGL 4.6)', + 'ANGLE (AMD, AMD Radeon RX 6700 XT (navi22, LLVM 16.0.6, DRM 3.54, LLVM 16.0.6), OpenGL 4.6)', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 OpenGL 4.6)', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 OpenGL 4.6)', ] : [ 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)', 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)', @@ -180,6 +190,10 @@ function _getFp() { 'Google Inc. (Apple)','Google Inc. (Apple)','Google Inc. (Apple)', 'Google Inc. (Apple)','Google Inc. (Apple)', 'Google Inc. (Intel Inc.)', + ] : isLinux ? [ + 'Google Inc. (Intel)','Google Inc. (Intel)','Google Inc. (Intel)', + 'Google Inc. (AMD)','Google Inc. (AMD)', + 'Google Inc. (NVIDIA)','Google Inc. (NVIDIA)', ] : [ 'Google Inc. (NVIDIA)','Google Inc. (NVIDIA)','Google Inc. (NVIDIA)', 'Google Inc. (Intel)','Google Inc. (Intel)', @@ -2612,8 +2626,35 @@ globalThis.NetworkInformation = NetworkInformation; globalThis.ContentIndex = class ContentIndex {}; +function _chromeMajor() { + var m = (globalThis.__obscura_ua || '').match(/Chrome\/(\d+)/); + return m ? (m[1] | 0) : 145; +} +// Chromium derives the sec-ch-ua GREASE brand, version, and brand order +// deterministically from the Chrome major version +// (components/embedder_support/user_agent_utils.cc). Replicating it keeps +// sec-ch-ua and userAgentData exact for every profile version rather than +// hardcoding one static token. +var _GREASE_CHARS = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_']; +var _GREASE_VER = ['8', '99', '24']; +var _BRAND_PERMS = [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]]; +function _uaBrands() { + var seed = _chromeMajor(); + var grease = { + brand: 'Not' + _GREASE_CHARS[seed % 11] + 'A' + _GREASE_CHARS[(seed + 1) % 11] + 'Brand', + version: _GREASE_VER[seed % 3], + }; + var ordered = [ + grease, + {brand: 'Chromium', version: String(seed)}, + {brand: 'Google Chrome', version: String(seed)}, + ]; + var p = _BRAND_PERMS[seed % 6]; + return [ordered[p[0]], ordered[p[1]], ordered[p[2]]]; +} + globalThis.navigator = { - get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; }, + get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; }, get appVersion() { return this.userAgent.replace('Mozilla/', ''); }, language: "en-US", languages: ["en-US","en"], get platform() { return globalThis.__obscura_platform || "Win32"; }, onLine: true, cookieEnabled: true, hardwareConcurrency: 8, @@ -2643,24 +2684,22 @@ globalThis.navigator = { return m; }, userAgentData: { - brands: [ - {brand: "Google Chrome", version: "145"}, - {brand: "Chromium", version: "145"}, - {brand: "Not;A=Brand", version: "24"}, - ], mobile: false, + get brands() { return _uaBrands(); }, get platform() { return globalThis.__obscura_ua_platform || "Windows"; }, getHighEntropyValues(hints) { + var brands = _uaBrands(); 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: brands, + fullVersionList: brands.map(function(b) { return {brand: b.brand, version: b.version + ".0.0.0"}; }), mobile: false, model: "", platform: globalThis.__obscura_ua_platform || "Windows", platformVersion: globalThis.__obscura_ua_platform_version || "15.0.0", - uaFullVersion: "145.0.0.0", + uaFullVersion: _chromeMajor() + ".0.0.0", + wow64: false, }); }, toJSON() { return {brands:this.brands,mobile:this.mobile,platform:this.platform}; }, @@ -6722,29 +6761,9 @@ globalThis.__obscura_init = function() { }; 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", - }); - }; - } + // userAgentData brands and getHighEntropyValues now derive the Chrome + // version from navigator.userAgent and read the platform from the page + // globals, so every stealth surface agrees without a per-mode override. // 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-net/src/lib.rs b/crates/obscura-net/src/lib.rs index e373575..f9840ad 100644 --- a/crates/obscura-net/src/lib.rs +++ b/crates/obscura-net/src/lib.rs @@ -19,4 +19,7 @@ pub use encoding::{ pub use robots::RobotsCache; pub use blocklist::is_blocked as is_tracker_blocked; #[cfg(feature = "stealth")] -pub use wreq_client::{StealthHttpClient, STEALTH_USER_AGENT}; +pub use wreq_client::{ + StealthHttpClient, STEALTH_NAVIGATOR_PLATFORM, STEALTH_UA_PLATFORM, + STEALTH_UA_PLATFORM_VERSION, STEALTH_USER_AGENT, +}; diff --git a/crates/obscura-net/src/wreq_client.rs b/crates/obscura-net/src/wreq_client.rs index e0405d4..1361640 100644 --- a/crates/obscura-net/src/wreq_client.rs +++ b/crates/obscura-net/src/wreq_client.rs @@ -19,7 +19,18 @@ use crate::client::{Response, ObscuraNetError}; #[cfg(feature = "stealth")] pub const STEALTH_USER_AGENT: &str = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; + +// The wreq emulation (Profile::Chrome145, Platform::Windows) sends this exact +// UA and sec-ch-ua-platform "Windows" on the wire. navigator has to report the +// same identity, otherwise the TLS/HTTP layer and the JS layer disagree and a +// site cross-checks the mismatch as a bot signal. +#[cfg(feature = "stealth")] +pub const STEALTH_NAVIGATOR_PLATFORM: &str = "Win32"; +#[cfg(feature = "stealth")] +pub const STEALTH_UA_PLATFORM: &str = "Windows"; +#[cfg(feature = "stealth")] +pub const STEALTH_UA_PLATFORM_VERSION: &str = "15.0.0"; #[cfg(feature = "stealth")] pub struct StealthHttpClient { @@ -38,7 +49,7 @@ impl StealthHttpClient { pub fn with_proxy(cookie_jar: Arc, proxy_url: Option<&str>) -> Self { let emulation_opts = wreq_util::Emulation::builder() .profile(wreq_util::Profile::Chrome145) - .platform(wreq_util::Platform::Linux) + .platform(wreq_util::Platform::Windows) .build(); let mut builder = wreq::Client::builder() From 2e0981ef3fcbd57d60ea07acedd7ba6105663b61 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:28:33 +0000 Subject: [PATCH 3/8] stealth: route scripted fetch()/XHR through the wreq client op_fetch_url backed JS fetch() and XHR with a plain reqwest/rustls client, so in stealth mode scripted requests carried a non-browser TLS fingerprint and none of the Chrome client hints the main navigation sends. Bot managers that score subresource requests (the AWS WAF challenge verify call, Akamai sensors) read that as a script and reject it. Add StealthHttpClient::send_single and a stealth_fetch_all path that op_fetch_url uses when a stealth client is installed, preserving the redirect, SSRF, and CORS handling so JS-level requests share the navigation's Chrome TLS fingerprint and sec-ch-ua client hints. The reqwest path is unchanged for non-stealth. Adds a stealth feature to obscura-js gated on obscura-net/stealth. --- crates/obscura-browser/Cargo.toml | 2 +- crates/obscura-browser/src/page.rs | 4 + crates/obscura-js/Cargo.toml | 7 ++ crates/obscura-js/src/ops.rs | 151 ++++++++++++++++++++++++++ crates/obscura-js/src/runtime.rs | 7 ++ crates/obscura-net/src/wreq_client.rs | 79 ++++++++++++++ 6 files changed, 249 insertions(+), 1 deletion(-) diff --git a/crates/obscura-browser/Cargo.toml b/crates/obscura-browser/Cargo.toml index 0179e94..1ed64cd 100644 --- a/crates/obscura-browser/Cargo.toml +++ b/crates/obscura-browser/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true [features] default = [] -stealth = ["obscura-net/stealth"] +stealth = ["obscura-net/stealth", "obscura-js/stealth"] [dependencies] obscura-dom = { workspace = true } diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 44fb44f..95ad7ae 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -319,6 +319,10 @@ impl Page { rt.set_cookie_jar(self.context.cookie_jar.clone()); rt.set_http_client(self.http_client.clone()); + #[cfg(feature = "stealth")] + if let Some(ref stealth) = self.stealth_client { + rt.set_stealth_client(stealth.clone()); + } if let Some(tx) = &self.intercept_tx { rt.set_intercept_tx(tx.clone()); diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml index c86b92f..29625cf 100644 --- a/crates/obscura-js/Cargo.toml +++ b/crates/obscura-js/Cargo.toml @@ -3,6 +3,13 @@ name = "obscura-js" version.workspace = true edition.workspace = true +[features] +default = [] +# Builds obscura-net with its stealth (wreq) HTTP client and lets scripted +# fetch()/XHR be routed through it so JS-level requests carry the same Chrome +# TLS fingerprint and client hints as stealth navigation. +stealth = ["obscura-net/stealth"] + [build-dependencies] deno_core = "0.350" diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 99cccd3..752f47e 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -9,6 +9,8 @@ use deno_core::Extension; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use obscura_dom::{DomTree, NodeData, NodeId}; use obscura_net::{CookieJar, ObscuraHttpClient}; +#[cfg(feature = "stealth")] +use obscura_net::StealthHttpClient; use tokio::sync::Mutex; pub type InterceptCallback = Arc Option<(u16, String, String)> + Send + Sync>>>>; @@ -55,6 +57,11 @@ pub struct ObscuraState { pub blocked_urls: Vec, pub cookie_jar: Option>, pub http_client: Option>, + /// When set (stealth mode), scripted fetch()/XHR is routed through the wreq + /// client so the request carries the Chrome TLS fingerprint and client + /// hints instead of the rustls ClientHello op_fetch_url would otherwise send. + #[cfg(feature = "stealth")] + pub stealth_client: Option>, pub pending_navigation: Option<(String, String, String)>, pub intercept_tx: Option>, pub intercept_counter: u64, @@ -82,6 +89,8 @@ impl ObscuraState { blocked_urls: Vec::new(), cookie_jar: None, http_client: None, + #[cfg(feature = "stealth")] + stealth_client: None, pending_navigation: None, intercept_tx: None, intercept_counter: 0, @@ -707,6 +716,34 @@ async fn op_fetch_url( let custom_headers: std::collections::HashMap = serde_json::from_str(&headers_json).unwrap_or_default(); + // Stealth mode: route the scripted request through the wreq client so its + // TLS fingerprint and Chrome client hints match the main navigation. The + // rustls ClientHello plus missing client hints that op_fetch_url's reqwest + // path sends otherwise read as a non-browser script to bot managers (the + // AWS WAF challenge verify call, Akamai sensors, etc.). + #[cfg(feature = "stealth")] + { + let stealth = { + let st = state.borrow(); + let gs = st.borrow::().clone(); + let client = gs.borrow().stealth_client.clone(); + client + }; + if let Some(stealth) = stealth { + return stealth_fetch_all( + stealth, + url.clone(), + req_method.as_str().to_string(), + custom_headers.clone(), + body.clone(), + page_origin.clone(), + is_cross_origin, + mode.clone(), + ) + .await; + } + } + let needs_preflight = is_cross_origin && mode == "cors" && (req_method != reqwest::Method::GET @@ -946,6 +983,120 @@ async fn op_fetch_url( .to_string()) } +/// Stealth-mode scripted fetch()/XHR: mirrors op_fetch_url's redirect, SSRF, +/// and CORS semantics but sends every hop through the wreq stealth client so +/// the request carries the Chrome TLS fingerprint and client hints. Cookie +/// handling lives inside StealthHttpClient::send_single, which shares the +/// context jar. Response bodies are not mirrored into the CDP +/// Network.getResponseBody buffer here; that is a follow-up for stealth fetches. +#[cfg(feature = "stealth")] +async fn stealth_fetch_all( + stealth: Arc, + url: String, + method: String, + custom_headers: HashMap, + body: String, + page_origin: String, + is_cross_origin: bool, + mode: String, +) -> Result { + let mut current_url = url.clone(); + let mut current_method = method; + let mut current_body = body; + let mut redirects_followed: usize = 0; + + let (status, resp_headers, resp_bytes): (u16, HashMap, Vec) = loop { + let parsed_current = match url::Url::parse(¤t_url) { + Ok(u) => u, + Err(_) => { + return Ok(serde_json::json!({ + "status": 0, "body": "", "url": current_url, "headers": {}, + }) + .to_string()); + } + }; + + let mut req_headers: HashMap = HashMap::new(); + if is_cross_origin { + req_headers.insert("origin".to_string(), page_origin.clone()); + } + for (k, v) in &custom_headers { + req_headers.insert(k.to_lowercase(), v.clone()); + } + + let r = stealth + .send_single(¤t_method, &parsed_current, &req_headers, ¤t_body) + .await + .map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?; + + if !(300..400).contains(&r.status) { + break (r.status, r.headers, r.body); + } + let Some(location) = r.headers.get("location").cloned() else { + break (r.status, r.headers, r.body); + }; + let next_url = match parsed_current.join(&location) { + Ok(u) => u, + Err(_) => break (r.status, r.headers, r.body), + }; + // Re-validate every redirect target against the SSRF policy, matching + // op_fetch_url (GHSA-8v6v-g4rh-jmcm). + if let Err(reason) = validate_fetch_url(&next_url) { + return Ok(serde_json::json!({ + "status": 0, "body": "", "url": next_url.to_string(), "headers": {}, + "blocked": true, + "error": format!("Redirect to forbidden URL blocked: {}", reason), + }) + .to_string()); + } + redirects_followed += 1; + if redirects_followed > FETCH_REDIRECT_LIMIT { + return Ok(serde_json::json!({ + "status": 0, "body": "", "url": next_url.to_string(), "headers": {}, + "blocked": true, + "error": format!("Too many redirects (>{})", FETCH_REDIRECT_LIMIT), + }) + .to_string()); + } + // Browser semantics: 301/302/303 downgrade to GET with no body. + if r.status == 301 || r.status == 302 || r.status == 303 { + current_method = "GET".to_string(); + current_body.clear(); + } + current_url = next_url.to_string(); + }; + + if is_cross_origin && mode == "cors" { + let allowed = resp_headers + .get("access-control-allow-origin") + .map(|s| s.as_str()) + .unwrap_or(""); + if allowed != "*" && allowed != page_origin { + return Ok(serde_json::json!({ + "status": 0, "body": "", "url": url, "headers": {}, + "corsBlocked": true, + "corsError": format!( + "CORS error: Origin '{}' not in Access-Control-Allow-Origin '{}'", + page_origin, allowed + ), + }) + .to_string()); + } + } + + let resp_body = String::from_utf8_lossy(&resp_bytes).to_string(); + let resp_body_base64 = BASE64.encode(&resp_bytes); + + Ok(serde_json::json!({ + "status": status, + "body": resp_body, + "bodyBase64": resp_body_base64, + "url": url, + "headers": resp_headers, + }) + .to_string()) +} + fn glob_match(pattern: &str, url: &str) -> bool { if pattern == "*" { return true; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index dcd7c96..f01c0bb 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -153,6 +153,13 @@ impl ObscuraJsRuntime { self.state.borrow_mut().http_client = Some(client); } + /// Install the stealth (wreq) HTTP client so scripted fetch()/XHR is routed + /// through it in stealth mode (see op_fetch_url / stealth_fetch_all). + #[cfg(feature = "stealth")] + pub fn set_stealth_client(&self, client: std::sync::Arc) { + self.state.borrow_mut().stealth_client = Some(client); + } + pub fn set_dom(&self, dom: DomTree) { self.state.borrow_mut().dom = Some(dom); } diff --git a/crates/obscura-net/src/wreq_client.rs b/crates/obscura-net/src/wreq_client.rs index 1361640..bf56a9a 100644 --- a/crates/obscura-net/src/wreq_client.rs +++ b/crates/obscura-net/src/wreq_client.rs @@ -154,6 +154,85 @@ impl StealthHttpClient { Err(ObscuraNetError::TooManyRedirects(url.to_string())) } + /// One request with no redirect following, for scripted fetch()/XHR. Reads + /// the cookie jar for the Cookie header and stores Set-Cookie back into it, + /// so the caller only owns redirect hops and SSRF re-validation. Used in + /// stealth mode so JS-level requests carry the same Chrome TLS fingerprint + /// and client hints as the main navigation instead of the rustls ClientHello + /// that op_fetch_url would otherwise send (which bot managers read as a + /// non-browser script and reject, e.g. the AWS WAF challenge verify call). + pub async fn send_single( + &self, + method: &str, + url: &Url, + headers: &HashMap, + body: &str, + ) -> Result { + if let Some(host) = url.host_str() { + if crate::blocklist::is_blocked(host) { + tracing::debug!("Blocked tracker: {}", url); + return Ok(Response { + status: 0, + url: url.clone(), + headers: HashMap::new(), + body: Vec::new(), + redirected_from: Vec::new(), + }); + } + } + + let req_method = method + .parse::() + .map_err(|e| ObscuraNetError::Network(format!("invalid method '{}': {}", method, e)))?; + let mut req = self.client.request(req_method, url.as_str()); + + let cookie_header = self.cookie_jar.get_cookie_header(url); + if !cookie_header.is_empty() { + req = req.header("cookie", &cookie_header); + } + for (k, v) in self.extra_headers.read().await.iter() { + req = req.header(k.as_str(), v.as_str()); + } + for (k, v) in headers.iter() { + req = req.header(k.as_str(), v.as_str()); + } + if !body.is_empty() { + req = req.body(body.to_string()); + } + + self.in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let resp = req.send().await.map_err(|e| { + self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + ObscuraNetError::Network(format!("{}: {}", url, e)) + })?; + self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + + let status = resp.status(); + for val in resp.headers().get_all("set-cookie") { + if let Ok(s) = val.to_str() { + self.cookie_jar.set_cookie(s, url); + } + } + let response_headers: HashMap = resp + .headers() + .iter() + .map(|(k, v)| (k.as_str().to_lowercase(), v.to_str().unwrap_or("").to_string())) + .collect(); + let resp_body = resp + .bytes() + .await + .map_err(|e| ObscuraNetError::Network(format!("Failed to read body: {}", e)))? + .to_vec(); + + Ok(Response { + url: url.clone(), + status: status.as_u16(), + headers: response_headers, + body: resp_body, + redirected_from: Vec::new(), + }) + } + pub async fn set_extra_headers(&self, headers: HashMap) { *self.extra_headers.write().await = headers; } From 402de261aacc1923723141895b8ccc76cb2f4f41 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:28:42 +0000 Subject: [PATCH 4/8] js: fix Blob-URL Worker race and update location synchronously on navigation URL.createObjectURL stored blob content asynchronously (blob.text().then) but the Worker constructor read __blobStore synchronously, so new Worker(blobURL) in the same tick raced the store, fell through to fetch(blobURL), and failed with net::ERR_FAILED. This broke Blob-URL workers including the AWS WAF challenge proof-of-work worker. Store synchronously from Blob._bytes, which the obscura Blob materializes in its constructor. location.reload was a no-op, so challenges that reload after setting a token cookie never re-fetched. It now navigates like assign/replace. location.href/assign/replace/reload cleared __virtualUrl immediately, so a script that set location.href and re-read it saw the old URL until the async navigation completed; real browsers update location.href synchronously. Preview the resolved target in __virtualUrl and clear it in __obscura_init once the navigation completes, so immediate reads match and redirects still win. --- crates/obscura-js/js/bootstrap.js | 34 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index a4e2d3a..445e8e0 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2499,7 +2499,7 @@ function __currentUrl() { } globalThis.location = { get href() { return __currentUrl(); }, - set href(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, + set href(url) { var r = _resolveUrl(url); globalThis.__virtualUrl = r; Deno.core.ops.op_navigate(r, 'GET', ''); }, get origin() { try { return new URL(this.href).origin; } catch { return ""; } }, get protocol() { try { return new URL(this.href).protocol; } catch { return ""; } }, get host() { try { return new URL(this.href).host; } catch { return ""; } }, @@ -2509,14 +2509,14 @@ globalThis.location = { get hash() { try { return new URL(this.href).hash; } catch { return ""; } }, get port() { try { return new URL(this.href).port; } catch { return ""; } }, toString() { return this.href; }, - assign(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, - reload() {}, - replace(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, + assign(url) { var r = _resolveUrl(url); globalThis.__virtualUrl = r; Deno.core.ops.op_navigate(r, 'GET', ''); }, + reload() { var r = _resolveUrl(this.href); globalThis.__virtualUrl = r; Deno.core.ops.op_navigate(r, 'GET', ''); }, + replace(url) { var r = _resolveUrl(url); globalThis.__virtualUrl = r; Deno.core.ops.op_navigate(r, 'GET', ''); }, }; const _locationObj = globalThis.location; Object.defineProperty(globalThis, 'location', { get() { return _locationObj; }, - set(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); }, + set(url) { var r = _resolveUrl(String(url)); globalThis.__virtualUrl = r; Deno.core.ops.op_navigate(r, 'GET', ''); }, configurable: false, enumerable: true, }); @@ -6218,11 +6218,25 @@ globalThis.Worker = class Worker { }; globalThis.__blobStore = globalThis.__blobStore || {}; -const _origCreateObjectURL = URL.createObjectURL; URL.createObjectURL = function(blob) { - if (blob && typeof blob.text === 'function') { + if (blob) { const id = 'blob:obscura/' + Math.random().toString(36).substring(2); - blob.text().then(text => { globalThis.__blobStore[id] = text; }); + // Store synchronously so a Worker built from the blob URL in the same + // tick sees its source. Blob-URL Worker construction is synchronous in + // real browsers; the previous async blob.text().then() store raced the + // Worker constructor, so new Worker(blobURL) fell through to fetch() and + // failed (net::ERR_FAILED), which broke AWS WAF's proof-of-work worker. + // The obscura Blob materializes _bytes in its constructor; fall back to + // the async text() store only for foreign Blob shims without _bytes. + if (blob._bytes) { + let text = ''; + try { text = new TextDecoder().decode(blob._bytes); } catch (e) {} + globalThis.__blobStore[id] = text; + } else if (typeof blob.text === 'function') { + blob.text().then(text => { globalThis.__blobStore[id] = text; }); + } else { + globalThis.__blobStore[id] = ''; + } return id; } return 'blob:obscura/fallback'; @@ -6733,6 +6747,10 @@ if (typeof ShadowRoot !== 'undefined' && !ShadowRoot.prototype.elementFromPoint) globalThis.__obscura_init = function() { _fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0); _fpCache = null; + // A real navigation just completed (this runs after set_url), so drop any + // URL a location setter previewed synchronously and let document_url drive + // location.href again, including any redirect target. + globalThis.__virtualUrl = null; _installWasmStreamingFallback(); globalThis.document = new Document(+_dom("document_node_id")); From 3eb28da5c6c18db2dbbdf39f9060dfbbf0f0b3e5 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:52:57 +0000 Subject: [PATCH 5/8] js: serialize FormData bodies to multipart/form-data in fetch fetch() and XHR did String(body), so passing a FormData as the request body sent the literal "[object Object]" and lost the multipart payload along with its boundary. Servers expecting multipart/form-data replied 400 "Invalid boundary for multipart/form-data", which is what blocked booking.com: the AWS WAF challenge POSTs its proof-of-work solution to /mp_verify as FormData, so the verify always failed and no aws-waf-token was issued. Serialize FormData to a multipart/form-data body with a generated boundary and set the matching Content-Type, the way a real browser does; URLSearchParams bodies get application/x-www-form-urlencoded. XHR.send routes through fetch() so it is covered too. With this, obscura --stealth renders the real booking.com landing page the same as headless Chrome. --- crates/obscura-js/js/bootstrap.js | 50 +++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 445e8e0..cced65f 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2906,6 +2906,51 @@ function _installWasmStreamingFallback() { } _installWasmStreamingFallback(); +// Serialize a FormData into a multipart/form-data body the way a browser does +// when it is passed as fetch()/XHR body. The previous shim did String(body), +// so a FormData became the literal "[object Object]" and the multipart payload +// (with its boundary) was lost; servers replied "Invalid boundary for +// multipart/form-data" (e.g. the AWS WAF challenge /mp_verify POST). +function _formDataToMultipart(fd) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let bnd = '----WebKitFormBoundary'; + for (let i = 0; i < 16; i++) bnd += chars[Math.floor(Math.random() * chars.length)]; + let out = ''; + const entries = fd._d || []; + for (let i = 0; i < entries.length; i++) { + const k = entries[i][0], v = entries[i][1]; + out += '--' + bnd + '\r\n'; + if (v != null && typeof v === 'object' && v._bytes != null) { + out += 'Content-Disposition: form-data; name="' + k + '"; filename="' + (v.name || 'blob') + '"\r\n'; + out += 'Content-Type: ' + (v.type || 'application/octet-stream') + '\r\n\r\n'; + try { out += new TextDecoder().decode(v._bytes); } catch (e) {} + out += '\r\n'; + } else { + out += 'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + String(v) + '\r\n'; + } + } + out += '--' + bnd + '--\r\n'; + return { boundary: bnd, body: out }; +} + +// Coerce a fetch()/XHR body into the string op_fetch_url expects, attaching a +// Content-Type header for body types that need one (FormData, URLSearchParams). +function _serializeBody(initBody, headers) { + if (initBody == null || initBody === '') return ''; + if (initBody instanceof FormData) { + const mp = _formDataToMultipart(initBody); + headers['Content-Type'] = 'multipart/form-data; boundary=' + mp.boundary; + return mp.body; + } + if (initBody instanceof URLSearchParams) { + if (!Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; + } + return initBody.toString(); + } + return typeof initBody === 'string' ? initBody : String(initBody); +} + globalThis.fetch = async (input, init = {}) => { let url = typeof input === "string" ? input @@ -2919,8 +2964,9 @@ globalThis.fetch = async (input, init = {}) => { } catch(e) { /* keep as-is if URL resolution fails */ } } const method = init.method || (input instanceof Request ? input.method : "GET"); - const hdrs = JSON.stringify(init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init.headers || {}); - const body = init.body ? String(init.body) : ""; + let _h = init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : (init.headers || {}); + const body = _serializeBody(init.body, _h); + const hdrs = JSON.stringify(_h); const fetchMode = init.mode || (input instanceof Request ? input.mode : "cors"); const pageOrigin = (function() { try { const u = new URL(_domParse("document_url") || "about:blank"); return u.origin; } catch(e) { return ""; } })(); const raw = await Deno.core.ops.op_fetch_url(url, method, hdrs, body, pageOrigin, fetchMode); From f7b3ff5d75fef33fbdb647b095ea90d2f7f50f23 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:37 +0000 Subject: [PATCH 6/8] net: derive sec-ch-ua client hints from the UA on the default HTTP path The non-stealth navigation path hardcoded sec-ch-ua-platform "Linux" and a fixed sec-ch-ua using Chrome 149's GREASE token, regardless of the selected profile. With the default Windows Chrome 143 profile that meant a Windows UA and navigator.userAgentData traveling with a Linux sec-ch-ua-platform header and mismatched brands, a cross-layer inconsistency a site can check at a glance. Derive both headers from the profile User-Agent using Chromium's per-major GREASE algorithm, so the HTTP client hints agree with the JS surfaces. --- crates/obscura-net/src/client.rs | 47 ++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index 5f7a561..2f699bb 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -273,6 +273,46 @@ pub struct ObscuraHttpClient { pub allow_private_network: bool, } +/// Derive the sec-ch-ua and sec-ch-ua-platform client-hint header values from a +/// User-Agent string, using Chromium's per-major-version GREASE algorithm so +/// the non-stealth HTTP path agrees with navigator.userAgentData instead of +/// shipping a fixed Linux/Chrome-145 hint that contradicts a Windows profile. +fn chrome_client_hints(ua: &str) -> (String, String) { + let major: usize = ua + .split("Chrome/") + .nth(1) + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(145); + const GREASE_CHARS: [char; 11] = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_']; + const GREASE_VER: [&str; 3] = ["8", "99", "24"]; + const PERMS: [[usize; 3]; 6] = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]; + let grease_brand = format!( + "Not{}A{}Brand", + GREASE_CHARS[major % 11], + GREASE_CHARS[(major + 1) % 11] + ); + let brands = [ + (grease_brand, GREASE_VER[major % 3].to_string()), + ("Chromium".to_string(), major.to_string()), + ("Google Chrome".to_string(), major.to_string()), + ]; + let p = PERMS[major % 6]; + let sec_ch_ua = p + .iter() + .map(|&i| format!("\"{}\";v=\"{}\"", brands[i].0, brands[i].1)) + .collect::>() + .join(", "); + let platform = if ua.contains("Windows NT") { + "\"Windows\"" + } else if ua.contains("Macintosh") { + "\"macOS\"" + } else { + "\"Linux\"" + }; + (sec_ch_ua, platform.to_string()) +} + impl ObscuraHttpClient { pub fn new() -> Self { Self::with_cookie_jar(Arc::new(CookieJar::new())) @@ -419,9 +459,11 @@ impl ObscuraHttpClient { reqwest::header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"), ); + let (sec_ch_ua, sec_ch_ua_platform) = chrome_client_hints(&ua); headers.insert( HeaderName::from_static("sec-ch-ua"), - HeaderValue::from_static("\"Chromium\";v=\"145\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"145\""), + HeaderValue::from_str(&sec_ch_ua) + .unwrap_or_else(|_| HeaderValue::from_static("\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"")), ); headers.insert( HeaderName::from_static("sec-ch-ua-mobile"), @@ -429,7 +471,8 @@ impl ObscuraHttpClient { ); headers.insert( HeaderName::from_static("sec-ch-ua-platform"), - HeaderValue::from_static("\"Linux\""), + HeaderValue::from_str(&sec_ch_ua_platform) + .unwrap_or_else(|_| HeaderValue::from_static("\"Windows\"")), ); headers.insert( HeaderName::from_static("sec-fetch-dest"), From db24d7a18c62ecb2faf11c3bb09f52b9b7047c8e Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:37 +0000 Subject: [PATCH 7/8] js: prompt for sensitive permissions in navigator.permissions.query permissions.query returned "granted" for every permission except notifications. Real Chrome defaults camera, microphone, geolocation, and midi to "prompt", so answering "granted" for them is a bot tell. Return "prompt" for those (keeping notifications in sync with Notification.permission) and "granted" for the rest. --- crates/obscura-js/js/bootstrap.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index cced65f..5c433b3 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2720,8 +2720,12 @@ globalThis.navigator = { }, clipboard: { writeText(){return Promise.resolve();}, readText(){return Promise.resolve("");} }, permissions: { query(params){ - if (params?.name === 'notifications') return Promise.resolve({state:"prompt",onchange:null}); - return Promise.resolve({state:"granted"}); + var n = params && params.name; + // Chrome defaults privacy-sensitive permissions to "prompt", not "granted"; + // returning "granted" for camera/microphone is a bot tell. + if (n === 'notifications') return Promise.resolve({state: (globalThis.Notification && Notification.permission === 'granted') ? 'granted' : 'prompt', onchange: null}); + if (n === 'geolocation' || n === 'camera' || n === 'microphone' || n === 'midi') return Promise.resolve({state: 'prompt', onchange: null}); + return Promise.resolve({state: 'granted', onchange: null}); } }, getBattery() { return Promise.resolve({ charging: _fp('batteryCharging'), chargingTime: _fp('batteryCharging') ? 0 : Infinity, dischargingTime: _fp('batteryCharging') ? Infinity : Math.floor(3600 + _fpRand(250) * 7200), level: _fp('batteryLevel'), addEventListener(){} }); }, getGamepads() { return []; }, From 768cbc056b8fb7b692e59696c431690d6fb12130 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:40:16 +0000 Subject: [PATCH 8/8] net: send non-stealth navigation headers in Chrome's order The default HTTP path emitted headers with user-agent first and sec-fetch-dest before sec-fetch-site, an order no real browser produces and a known fingerprint signal. Reorder the explicit headers to Chrome's top-level navigation order (sec-ch-ua family, upgrade-insecure-requests, user-agent, accept, sec-fetch-site/mode/user/dest, accept-language). reqwest still appends accept-encoding and host after these, so accept-encoding lands after accept-language instead of before it; the rest matches Chrome exactly. --- crates/obscura-net/src/client.rs | 49 +++++++++++--------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index 2f699bb..83d0e5f 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -447,52 +447,37 @@ impl ObscuraHttpClient { } let ua = self.user_agent.read().await.clone(); - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_str(&ua).unwrap_or_else(|_| { - HeaderValue::from_static("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") - })); - headers.insert( - reqwest::header::ACCEPT, - HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"), - ); - headers.insert( - reqwest::header::ACCEPT_LANGUAGE, - HeaderValue::from_static("en-US,en;q=0.9"), - ); let (sec_ch_ua, sec_ch_ua_platform) = chrome_client_hints(&ua); + let mut headers = HeaderMap::new(); + // Chrome's top-level navigation header order. (reqwest appends + // accept-encoding/host after these, so accept-encoding lands after + // accept-language rather than before it; the rest matches Chrome.) headers.insert( HeaderName::from_static("sec-ch-ua"), HeaderValue::from_str(&sec_ch_ua) .unwrap_or_else(|_| HeaderValue::from_static("\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"")), ); - headers.insert( - HeaderName::from_static("sec-ch-ua-mobile"), - HeaderValue::from_static("?0"), - ); + headers.insert(HeaderName::from_static("sec-ch-ua-mobile"), HeaderValue::from_static("?0")); headers.insert( HeaderName::from_static("sec-ch-ua-platform"), HeaderValue::from_str(&sec_ch_ua_platform) .unwrap_or_else(|_| HeaderValue::from_static("\"Windows\"")), ); + headers.insert(HeaderName::from_static("upgrade-insecure-requests"), HeaderValue::from_static("1")); + headers.insert(USER_AGENT, HeaderValue::from_str(&ua).unwrap_or_else(|_| { + HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + })); headers.insert( - HeaderName::from_static("sec-fetch-dest"), - HeaderValue::from_static("document"), + reqwest::header::ACCEPT, + HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"), ); + headers.insert(HeaderName::from_static("sec-fetch-site"), HeaderValue::from_static("none")); + headers.insert(HeaderName::from_static("sec-fetch-mode"), HeaderValue::from_static("navigate")); + headers.insert(HeaderName::from_static("sec-fetch-user"), HeaderValue::from_static("?1")); + headers.insert(HeaderName::from_static("sec-fetch-dest"), HeaderValue::from_static("document")); headers.insert( - HeaderName::from_static("sec-fetch-mode"), - HeaderValue::from_static("navigate"), - ); - headers.insert( - HeaderName::from_static("sec-fetch-site"), - HeaderValue::from_static("none"), - ); - headers.insert( - HeaderName::from_static("sec-fetch-user"), - HeaderValue::from_static("?1"), - ); - headers.insert( - HeaderName::from_static("upgrade-insecure-requests"), - HeaderValue::from_static("1"), + reqwest::header::ACCEPT_LANGUAGE, + HeaderValue::from_static("en-US,en;q=0.9"), ); let cookie_header = self.cookie_jar.get_cookie_header(¤t_url);