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.
This commit is contained in:
SGavrl committed 2026-06-23 10:28:33 +00:00
1 parent 4309935abd
commit 2e0981ef3f
6 files changed
+249 -1

No files matched your search

+79
View File
@@ -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<String, String>,
body: &str,
) -> Result<Response, ObscuraNetError> {
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::<wreq::Method>()
.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<String, String> = 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<String, String>) {
*self.extra_headers.write().await = headers;
}