Harden typed resource transport
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -1989,6 +1989,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -4352,9 +4353,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-btls",
|
||||
"tokio-socks",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"url",
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["wreq", "wreq-util"]
|
||||
stealth = ["wreq", "wreq-util", "futures-util"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
@@ -18,6 +18,7 @@ thiserror = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
encoding_rs = "0.8"
|
||||
tempfile = "3"
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
# wreq and wreq-util are pre-release crates whose API changes between rc builds
|
||||
# (CertStore moved out of `tls`, `cert_store` renamed to `tls_cert_store`, the
|
||||
# Emulation builder reshuffled). A caret requirement let a fresh resolve pull a
|
||||
@@ -39,7 +40,7 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring",
|
||||
# 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.29", optional = true, features = ["prefix-symbols", "socks", "gzip", "brotli", "deflate", "zstd"] }
|
||||
wreq = { version = "=6.0.0-rc.29", optional = true, features = ["prefix-symbols", "socks", "stream", "gzip", "brotli", "deflate", "zstd"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "linux", target_os = "android")))'.dependencies]
|
||||
wreq = { version = "=6.0.0-rc.29", optional = true, features = ["socks", "gzip", "brotli", "deflate", "zstd"] }
|
||||
wreq = { version = "=6.0.0-rc.29", optional = true, features = ["socks", "stream", "gzip", "brotli", "deflate", "zstd"] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
@@ -123,7 +124,7 @@ pub struct RequestInfo {
|
||||
pub resource_type: ResourceType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceType {
|
||||
Document,
|
||||
Script,
|
||||
@@ -154,7 +155,7 @@ pub enum RequestCredentials {
|
||||
}
|
||||
|
||||
impl RequestMode {
|
||||
fn header_value(self) -> &'static str {
|
||||
pub(crate) fn header_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Navigate => "navigate",
|
||||
Self::NoCors => "no-cors",
|
||||
@@ -170,6 +171,9 @@ pub struct ResourceRequest {
|
||||
pub initiator: Option<Url>,
|
||||
pub mode: RequestMode,
|
||||
pub credentials: RequestCredentials,
|
||||
/// Hard limit for the decoded response body retained by this request.
|
||||
/// Callers can lower it for especially constrained resource consumers.
|
||||
pub max_response_bytes: usize,
|
||||
}
|
||||
|
||||
impl ResourceRequest {
|
||||
@@ -179,6 +183,7 @@ impl ResourceRequest {
|
||||
initiator: None,
|
||||
mode: RequestMode::Navigate,
|
||||
credentials: RequestCredentials::Include,
|
||||
max_response_bytes: 64 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +211,23 @@ impl ResourceRequest {
|
||||
initiator: Some(initiator.clone()),
|
||||
mode,
|
||||
credentials,
|
||||
max_response_bytes: match resource_type {
|
||||
ResourceType::Stylesheet | ResourceType::Font => 16 * 1024 * 1024,
|
||||
ResourceType::Script | ResourceType::Other => 32 * 1024 * 1024,
|
||||
ResourceType::Document
|
||||
| ResourceType::Image
|
||||
| ResourceType::Xhr
|
||||
| ResourceType::Fetch => 64 * 1024 * 1024,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn destination(&self) -> &'static str {
|
||||
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
|
||||
self.max_response_bytes = max_response_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn destination(&self) -> &'static str {
|
||||
match self.resource_type {
|
||||
ResourceType::Document => "document",
|
||||
ResourceType::Script => "script",
|
||||
@@ -220,7 +238,7 @@ impl ResourceRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&self) -> &'static str {
|
||||
pub(crate) fn accept(&self) -> &'static str {
|
||||
match self.resource_type {
|
||||
ResourceType::Document => "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",
|
||||
ResourceType::Stylesheet => "text/css,*/*;q=0.1",
|
||||
@@ -236,7 +254,7 @@ impl ResourceRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn sends_credentials_to(&self, target: &Url) -> bool {
|
||||
pub(crate) fn sends_credentials_to(&self, target: &Url) -> bool {
|
||||
match self.credentials {
|
||||
RequestCredentials::Omit => false,
|
||||
RequestCredentials::Include => true,
|
||||
@@ -248,7 +266,124 @@ impl ResourceRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_fetch_site(request: &ResourceRequest, target: &Url) -> &'static str {
|
||||
pub(crate) struct InFlightGuard {
|
||||
counter: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl InFlightGuard {
|
||||
pub(crate) fn new(counter: &Arc<AtomicU32>) -> Self {
|
||||
counter.fetch_add(1, Ordering::AcqRel);
|
||||
Self {
|
||||
counter: counter.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn same_origin(request: &ResourceRequest, target: &Url) -> bool {
|
||||
request
|
||||
.initiator
|
||||
.as_ref()
|
||||
.is_some_and(|initiator| initiator.origin() == target.origin())
|
||||
}
|
||||
|
||||
pub(crate) fn cors_required(request: &ResourceRequest, target: &Url) -> bool {
|
||||
request.mode == RequestMode::Cors && !same_origin(request, target)
|
||||
}
|
||||
|
||||
/// Serialize the request origin used by both the Origin request header and the
|
||||
/// response CORS check. A redirect chain that changes origin after it has
|
||||
/// already left the initiator origin is tainted and serializes to `null`.
|
||||
pub(crate) fn serialized_request_origin(
|
||||
request: &ResourceRequest,
|
||||
redirect_tainted: bool,
|
||||
) -> String {
|
||||
if redirect_tainted {
|
||||
return "null".to_string();
|
||||
}
|
||||
request
|
||||
.initiator
|
||||
.as_ref()
|
||||
.filter(|url| matches!(url.scheme(), "http" | "https"))
|
||||
.map(|url| url.origin().ascii_serialization())
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn redirect_taints_origin(
|
||||
request: &ResourceRequest,
|
||||
current: &Url,
|
||||
next: &Url,
|
||||
) -> bool {
|
||||
current.origin() != next.origin()
|
||||
&& request
|
||||
.initiator
|
||||
.as_ref()
|
||||
.is_none_or(|initiator| initiator.origin() != current.origin())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_request_mode(
|
||||
request: &ResourceRequest,
|
||||
target: &Url,
|
||||
) -> Result<(), ObscuraNetError> {
|
||||
if request.mode == RequestMode::SameOrigin && !same_origin(request, target) {
|
||||
return Err(ObscuraNetError::Cors(format!(
|
||||
"same-origin request blocked for {}",
|
||||
target
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_cors_response(
|
||||
request: &ResourceRequest,
|
||||
target: &Url,
|
||||
serialized_origin: &str,
|
||||
allow_origin: Option<&str>,
|
||||
allow_credentials: Option<&str>,
|
||||
) -> Result<(), ObscuraNetError> {
|
||||
if !cors_required(request, target) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let allow_origin = allow_origin.ok_or_else(|| {
|
||||
ObscuraNetError::Cors(format!(
|
||||
"{} did not include Access-Control-Allow-Origin for origin {}",
|
||||
target, serialized_origin
|
||||
))
|
||||
})?;
|
||||
if request.credentials != RequestCredentials::Include && allow_origin == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if allow_origin != serialized_origin {
|
||||
return Err(ObscuraNetError::Cors(format!(
|
||||
"{} returned Access-Control-Allow-Origin {:?}, expected {:?}",
|
||||
target, allow_origin, serialized_origin
|
||||
)));
|
||||
}
|
||||
if request.credentials == RequestCredentials::Include
|
||||
&& allow_credentials != Some("true")
|
||||
{
|
||||
return Err(ObscuraNetError::Cors(format!(
|
||||
"credentialed response from {} requires Access-Control-Allow-Credentials: true",
|
||||
target
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn response_too_large(url: &Url, limit: usize) -> ObscuraNetError {
|
||||
ObscuraNetError::ResponseTooLarge {
|
||||
url: url.to_string(),
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_fetch_site(request: &ResourceRequest, target: &Url) -> &'static str {
|
||||
let Some(initiator) = request.initiator.as_ref() else {
|
||||
return "none";
|
||||
};
|
||||
@@ -262,7 +397,7 @@ fn request_fetch_site(request: &ResourceRequest, target: &Url) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_referrer(request: &ResourceRequest, target: &Url) -> Option<String> {
|
||||
pub(crate) fn request_referrer(request: &ResourceRequest, target: &Url) -> Option<String> {
|
||||
let source = request.initiator.as_ref()?;
|
||||
if !matches!(source.scheme(), "http" | "https")
|
||||
|| !matches!(target.scheme(), "http" | "https")
|
||||
@@ -479,7 +614,7 @@ impl Resolve for SsrfGuardResolver {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNetError> {
|
||||
pub(crate) fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNetError> {
|
||||
let allow_private_network = allow_private_network || env_allows_private_network();
|
||||
let scheme = url.scheme();
|
||||
if scheme != "http" && scheme != "https" && scheme != "file" {
|
||||
@@ -530,13 +665,24 @@ fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNet
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_file_url(url: &Url) -> Result<Response, ObscuraNetError> {
|
||||
pub(crate) async fn fetch_file_url(
|
||||
url: &Url,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Response, ObscuraNetError> {
|
||||
let path = url
|
||||
.to_file_path()
|
||||
.map_err(|_| ObscuraNetError::Network("Invalid file URL".to_string()))?;
|
||||
if let Ok(metadata) = tokio::fs::metadata(&path).await {
|
||||
if metadata.len() > max_response_bytes as u64 {
|
||||
return Err(response_too_large(url, max_response_bytes));
|
||||
}
|
||||
}
|
||||
let body = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|e| ObscuraNetError::Network(format!("Failed to read file: {}", e)))?;
|
||||
if body.len() > max_response_bytes {
|
||||
return Err(response_too_large(url, max_response_bytes));
|
||||
}
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
@@ -565,6 +711,98 @@ async fn fetch_file_url(url: &Url) -> Result<Response, ObscuraNetError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn response_header_value<'a>(
|
||||
headers: &'a HeaderMap,
|
||||
name: &'static str,
|
||||
url: &Url,
|
||||
) -> Result<Option<&'a str>, ObscuraNetError> {
|
||||
let mut values = headers.get_all(name).iter();
|
||||
let Some(first) = values.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return Err(ObscuraNetError::Cors(format!(
|
||||
"{} returned multiple {} headers",
|
||||
url, name
|
||||
)));
|
||||
}
|
||||
first.to_str().map(Some).map_err(|_| {
|
||||
ObscuraNetError::Cors(format!("{} returned an invalid {} header", url, name))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reqwest_cors_response(
|
||||
request: &ResourceRequest,
|
||||
target: &Url,
|
||||
serialized_origin: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(), ObscuraNetError> {
|
||||
if !cors_required(request, target) {
|
||||
return Ok(());
|
||||
}
|
||||
let allow_origin = response_header_value(
|
||||
headers,
|
||||
"access-control-allow-origin",
|
||||
target,
|
||||
)?;
|
||||
let allow_credentials = response_header_value(
|
||||
headers,
|
||||
"access-control-allow-credentials",
|
||||
target,
|
||||
)?;
|
||||
validate_cors_response(
|
||||
request,
|
||||
target,
|
||||
serialized_origin,
|
||||
allow_origin,
|
||||
allow_credentials,
|
||||
)
|
||||
}
|
||||
|
||||
fn reject_oversized_content_length(
|
||||
headers: &HeaderMap,
|
||||
url: &Url,
|
||||
limit: usize,
|
||||
) -> Result<(), ObscuraNetError> {
|
||||
let Some(value) = headers.get(reqwest::header::CONTENT_LENGTH) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(value) = value.to_str() else {
|
||||
return Ok(());
|
||||
};
|
||||
if value
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.is_ok_and(|length| length > limit as u64)
|
||||
{
|
||||
return Err(response_too_large(url, limit));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_reqwest_body_limited(
|
||||
mut response: reqwest::Response,
|
||||
url: &Url,
|
||||
limit: usize,
|
||||
) -> Result<Vec<u8>, ObscuraNetError> {
|
||||
reject_oversized_content_length(response.headers(), url, limit)?;
|
||||
let capacity = response
|
||||
.content_length()
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
.unwrap_or(0)
|
||||
.min(limit);
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
while let Some(chunk) = response.chunk().await.map_err(|error| {
|
||||
ObscuraNetError::Network(format!("Failed to read body: {}", error))
|
||||
})? {
|
||||
if chunk.len() > limit.saturating_sub(body.len()) {
|
||||
return Err(response_too_large(url, limit));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub struct ObscuraHttpClient {
|
||||
client: tokio::sync::OnceCell<Client>,
|
||||
proxy_url: Option<String>,
|
||||
@@ -659,7 +897,7 @@ impl ObscuraHttpClient {
|
||||
self.client.get_or_init(|| async {
|
||||
let mut builder = Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.timeout(self.timeout)
|
||||
.danger_accept_invalid_certs(false)
|
||||
// SSRF guard: reject hostnames that resolve to a private/loopback IP.
|
||||
.dns_resolver(Arc::new(SsrfGuardResolver::new(self.allow_private_network)))
|
||||
@@ -770,9 +1008,10 @@ impl ObscuraHttpClient {
|
||||
request: ResourceRequest,
|
||||
) -> Result<Response, ObscuraNetError> {
|
||||
validate_url(url, self.allow_private_network)?;
|
||||
validate_request_mode(&request, url)?;
|
||||
|
||||
if url.scheme() == "file" {
|
||||
return fetch_file_url(url).await;
|
||||
return fetch_file_url(url, request.max_response_bytes).await;
|
||||
}
|
||||
|
||||
let mut method = initial_method;
|
||||
@@ -795,8 +1034,11 @@ impl ObscuraHttpClient {
|
||||
let mut current_url = url.clone();
|
||||
let mut redirects = Vec::new();
|
||||
let max_redirects = 20;
|
||||
let mut redirect_tainted = false;
|
||||
let mut request_callback_fired = false;
|
||||
|
||||
for _redirect_count in 0..max_redirects {
|
||||
validate_request_mode(&request, ¤t_url)?;
|
||||
let request_info = RequestInfo {
|
||||
url: current_url.clone(),
|
||||
method: method.to_string(),
|
||||
@@ -820,8 +1062,11 @@ impl ObscuraHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cbs) = callbacks {
|
||||
cbs.fire_request(&request_info).await;
|
||||
if !request_callback_fired {
|
||||
if let Some(cbs) = callbacks {
|
||||
cbs.fire_request(&request_info).await;
|
||||
}
|
||||
request_callback_fired = true;
|
||||
}
|
||||
|
||||
let ua = self.user_agent.read().await.clone();
|
||||
@@ -871,6 +1116,7 @@ impl ObscuraHttpClient {
|
||||
headers.insert(reqwest::header::REFERER, value);
|
||||
}
|
||||
}
|
||||
let request_origin = serialized_request_origin(&request, redirect_tainted);
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT_LANGUAGE,
|
||||
HeaderValue::from_static("en-US,en;q=0.9"),
|
||||
@@ -919,6 +1165,15 @@ impl ObscuraHttpClient {
|
||||
headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
// Origin is a forbidden browser request header. Keep it derived
|
||||
// from the initiator even when callers supplied extra headers.
|
||||
if cors_required(&request, ¤t_url) {
|
||||
if let Ok(value) = HeaderValue::from_str(&request_origin) {
|
||||
headers.insert(reqwest::header::ORIGIN, value);
|
||||
}
|
||||
} else {
|
||||
headers.remove(reqwest::header::ORIGIN);
|
||||
}
|
||||
|
||||
let mut req_builder = self.get_client().await.request(method.clone(), current_url.as_str())
|
||||
.headers(headers);
|
||||
@@ -933,18 +1188,24 @@ impl ObscuraHttpClient {
|
||||
req_builder = req_builder.body(b.clone());
|
||||
}
|
||||
|
||||
self.in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let in_flight = InFlightGuard::new(&self.in_flight);
|
||||
let resp = req_builder.send().await.map_err(|e| {
|
||||
self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
ObscuraNetError::Network(format!("{}: {}", current_url, e))
|
||||
})?;
|
||||
self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let status = resp.status();
|
||||
validate_reqwest_cors_response(
|
||||
&request,
|
||||
¤t_url,
|
||||
&request_origin,
|
||||
resp.headers(),
|
||||
)?;
|
||||
|
||||
for val in resp.headers().get_all(reqwest::header::SET_COOKIE) {
|
||||
if let Ok(s) = val.to_str() {
|
||||
self.cookie_jar.set_cookie(s, ¤t_url);
|
||||
if request.sends_credentials_to(¤t_url) {
|
||||
for val in resp.headers().get_all(reqwest::header::SET_COOKIE) {
|
||||
if let Ok(s) = val.to_str() {
|
||||
self.cookie_jar.set_cookie(s, ¤t_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,6 +1224,9 @@ impl ObscuraHttpClient {
|
||||
ObscuraNetError::Network(format!("Invalid redirect URL: {}", e))
|
||||
})?;
|
||||
validate_url(&next_url, self.allow_private_network)?;
|
||||
validate_request_mode(&request, &next_url)?;
|
||||
redirect_tainted |=
|
||||
redirect_taints_origin(&request, ¤t_url, &next_url);
|
||||
redirects.push(current_url.clone());
|
||||
current_url = next_url;
|
||||
if status == reqwest::StatusCode::MOVED_PERMANENTLY
|
||||
@@ -976,9 +1240,13 @@ impl ObscuraHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
let body_bytes = resp.bytes().await.map_err(|e| {
|
||||
ObscuraNetError::Network(format!("Failed to read body: {}", e))
|
||||
})?.to_vec();
|
||||
let body_bytes = read_reqwest_body_limited(
|
||||
resp,
|
||||
¤t_url,
|
||||
request.max_response_bytes,
|
||||
)
|
||||
.await?;
|
||||
drop(in_flight);
|
||||
|
||||
let response = Response {
|
||||
url: current_url,
|
||||
@@ -1031,20 +1299,27 @@ pub enum ObscuraNetError {
|
||||
|
||||
#[error("Request blocked: {0}")]
|
||||
Blocked(String),
|
||||
|
||||
#[error("CORS error: {0}")]
|
||||
Cors(String),
|
||||
|
||||
#[error("Response body exceeded {limit} byte limit: {url}")]
|
||||
ResponseTooLarge { url: String, limit: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssrf_tests {
|
||||
use super::{
|
||||
is_forbidden_ip, request_fetch_site, request_referrer, validate_url,
|
||||
ObscuraHttpClient, RequestCredentials, RequestMode, ResourceRequest, ResourceType,
|
||||
SsrfGuardResolver,
|
||||
CallbackRegistry, ObscuraHttpClient, ObscuraNetError, RequestCredentials, RequestMode,
|
||||
ResourceRequest, ResourceType, SsrfGuardResolver,
|
||||
};
|
||||
use crate::cookies::CookieJar;
|
||||
use reqwest::dns::{Name, Resolve};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use url::Url;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
@@ -1156,6 +1431,250 @@ mod ssrf_tests {
|
||||
assert_eq!(request_referrer(&request, &downgrade), None);
|
||||
}
|
||||
|
||||
async fn http_fixture(
|
||||
responses: Vec<String>,
|
||||
) -> (Url, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (request_tx, request_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
for response in responses {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0u8; 2048];
|
||||
loop {
|
||||
let Ok(read) = stream.read(&mut buffer).await else {
|
||||
return;
|
||||
};
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = request_tx.send(String::from_utf8_lossy(&request).into_owned());
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
});
|
||||
(
|
||||
Url::parse(&format!("http://{address}/resource")).unwrap(),
|
||||
request_rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn ok_response(headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_origin_font_sends_origin_and_omits_cross_origin_cookies() {
|
||||
let (target, mut received) = http_fixture(vec![ok_response(
|
||||
"Access-Control-Allow-Origin: *\r\nSet-Cookie: rejected=1; Path=/\r\n",
|
||||
"font",
|
||||
)])
|
||||
.await;
|
||||
let initiator = Url::parse("http://127.0.0.1:1/page").unwrap();
|
||||
let jar = Arc::new(CookieJar::new());
|
||||
jar.set_cookie("seed=1; Path=/", &target);
|
||||
let client = ObscuraHttpClient::with_full_options(jar.clone(), None, true);
|
||||
|
||||
let response = client
|
||||
.fetch_resource_with_callbacks(
|
||||
&target,
|
||||
ResourceRequest::subresource(ResourceType::Font, &initiator),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.body, b"font");
|
||||
let request = received.recv().await.unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("origin: http://127.0.0.1:1\r\n"));
|
||||
assert!(request.contains("sec-fetch-mode: cors\r\n"));
|
||||
assert!(request.contains("sec-fetch-dest: font\r\n"));
|
||||
assert!(!request.contains("cookie:"));
|
||||
assert_eq!(jar.get_cookie_header(&target), "seed=1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentialed_cors_rejects_wildcard_and_accepts_exact_origin() {
|
||||
let initiator = Url::parse("http://127.0.0.1:1/page").unwrap();
|
||||
let wildcard = ok_response(
|
||||
"Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Credentials: true\r\n",
|
||||
"blocked",
|
||||
);
|
||||
let exact = ok_response(
|
||||
"Access-Control-Allow-Origin: http://127.0.0.1:1\r\nAccess-Control-Allow-Credentials: true\r\nSet-Cookie: accepted=1; Path=/\r\n",
|
||||
"allowed",
|
||||
);
|
||||
let (target, mut received) = http_fixture(vec![wildcard, exact]).await;
|
||||
let jar = Arc::new(CookieJar::new());
|
||||
jar.set_cookie("seed=1; Path=/", &target);
|
||||
let client = ObscuraHttpClient::with_full_options(jar.clone(), None, true);
|
||||
let mut request = ResourceRequest::subresource(ResourceType::Image, &initiator);
|
||||
request.mode = RequestMode::Cors;
|
||||
request.credentials = RequestCredentials::Include;
|
||||
|
||||
let error = client
|
||||
.fetch_resource_with_callbacks(&target, request.clone(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ObscuraNetError::Cors(_)));
|
||||
client
|
||||
.fetch_resource_with_callbacks(&target, request, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = received.recv().await.unwrap().to_ascii_lowercase();
|
||||
let second = received.recv().await.unwrap().to_ascii_lowercase();
|
||||
assert!(first.contains("cookie: seed=1\r\n"));
|
||||
assert!(second.contains("cookie: seed=1\r\n"));
|
||||
let cookies = jar.get_cookie_header(&target);
|
||||
assert!(cookies.contains("seed=1"));
|
||||
assert!(cookies.contains("accepted=1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_origin_font_needs_no_cors_header_and_sends_cookies() {
|
||||
let (target, mut received) = http_fixture(vec![ok_response("", "same")]).await;
|
||||
let mut initiator = target.clone();
|
||||
initiator.set_path("/page");
|
||||
let jar = Arc::new(CookieJar::new());
|
||||
jar.set_cookie("same=1; Path=/", &target);
|
||||
let client = ObscuraHttpClient::with_full_options(jar, None, true);
|
||||
client
|
||||
.fetch_resource_with_callbacks(
|
||||
&target,
|
||||
ResourceRequest::subresource(ResourceType::Font, &initiator),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let request = received.recv().await.unwrap().to_ascii_lowercase();
|
||||
assert!(!request.contains("origin:"));
|
||||
assert!(request.contains("cookie: same=1\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_limits_reject_content_length_and_streamed_overflow() {
|
||||
let advertised = "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n";
|
||||
let chunked = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n4\r\nabcd\r\n4\r\nefgh\r\n0\r\n\r\n";
|
||||
let (target, _) = http_fixture(vec![advertised.to_string(), chunked.to_string()]).await;
|
||||
let client = ObscuraHttpClient::with_full_options(
|
||||
Arc::new(CookieJar::new()),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let initiator = target.clone();
|
||||
let request = ResourceRequest::subresource(ResourceType::Image, &initiator)
|
||||
.with_max_response_bytes(6);
|
||||
|
||||
for _ in 0..2 {
|
||||
let error = client
|
||||
.fetch_resource_with_callbacks(&target, request.clone(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ObscuraNetError::ResponseTooLarge { limit: 6, .. }
|
||||
));
|
||||
assert_eq!(client.active_requests(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn hanging_fixture() -> (Url, tokio::sync::oneshot::Receiver<()>) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut buffer = [0u8; 2048];
|
||||
let _ = stream.read(&mut buffer).await;
|
||||
let _ = started_tx.send(());
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
});
|
||||
(
|
||||
Url::parse(&format!("http://{address}/hang")).unwrap(),
|
||||
started_rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_returns_active_requests_to_zero() {
|
||||
let (target, started) = hanging_fixture().await;
|
||||
let client = Arc::new(ObscuraHttpClient::with_full_options(
|
||||
Arc::new(CookieJar::new()),
|
||||
None,
|
||||
true,
|
||||
));
|
||||
let task = tokio::spawn({
|
||||
let client = client.clone();
|
||||
async move { client.fetch(&target).await }
|
||||
});
|
||||
started.await.unwrap();
|
||||
assert_eq!(client.active_requests(), 1);
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
assert_eq!(client.active_requests(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_timeout_returns_active_requests_to_zero() {
|
||||
let (target, started) = hanging_fixture().await;
|
||||
let mut client = ObscuraHttpClient::with_full_options(
|
||||
Arc::new(CookieJar::new()),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
client.timeout = std::time::Duration::from_millis(25);
|
||||
let fetch = client.fetch(&target);
|
||||
let (_, result) = tokio::join!(started, fetch);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(client.active_requests(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callbacks_fire_once_across_redirects() {
|
||||
let redirect = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let (target, _) = http_fixture(vec![redirect.to_string(), ok_response("", "done")]).await;
|
||||
let client = ObscuraHttpClient::with_full_options(
|
||||
Arc::new(CookieJar::new()),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let callbacks = CallbackRegistry::new();
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let responses = Arc::new(AtomicUsize::new(0));
|
||||
let request_count = requests.clone();
|
||||
callbacks.add_request(Arc::new(move |_| {
|
||||
request_count.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let response_count = responses.clone();
|
||||
callbacks.add_response(Arc::new(move |_, _| {
|
||||
response_count.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
|
||||
client
|
||||
.fetch_with_callbacks(&target, Some(&callbacks))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(responses.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_blocks_hostname_that_resolves_to_loopback() {
|
||||
// localtest.me is a public DNS name that resolves to 127.0.0.1 — the
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::sync::Arc;
|
||||
#[cfg(feature = "stealth")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
use futures_util::StreamExt;
|
||||
#[cfg(feature = "stealth")]
|
||||
use tokio::sync::RwLock;
|
||||
#[cfg(feature = "stealth")]
|
||||
@@ -15,7 +17,12 @@ use url::Url;
|
||||
#[cfg(feature = "stealth")]
|
||||
use crate::cookies::CookieJar;
|
||||
#[cfg(feature = "stealth")]
|
||||
use crate::client::{Response, ObscuraNetError};
|
||||
use crate::client::{
|
||||
CallbackRegistry, InFlightGuard, ObscuraNetError, RequestInfo, RequestMode,
|
||||
ResourceRequest, Response, cors_required, fetch_file_url, redirect_taints_origin,
|
||||
request_fetch_site, request_referrer, response_too_large, serialized_request_origin,
|
||||
validate_cors_response, validate_request_mode, validate_url,
|
||||
};
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
pub const STEALTH_USER_AGENT: &str =
|
||||
@@ -32,6 +39,86 @@ pub const STEALTH_UA_PLATFORM: &str = "Windows";
|
||||
#[cfg(feature = "stealth")]
|
||||
pub const STEALTH_UA_PLATFORM_VERSION: &str = "15.0.0";
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
fn wreq_response_header_value<'a>(
|
||||
headers: &'a wreq::header::HeaderMap,
|
||||
name: &'static str,
|
||||
url: &Url,
|
||||
) -> Result<Option<&'a str>, ObscuraNetError> {
|
||||
let mut values = headers.get_all(name).iter();
|
||||
let Some(first) = values.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return Err(ObscuraNetError::Cors(format!(
|
||||
"{} returned multiple {} headers",
|
||||
url, name
|
||||
)));
|
||||
}
|
||||
first.to_str().map(Some).map_err(|_| {
|
||||
ObscuraNetError::Cors(format!("{} returned an invalid {} header", url, name))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
fn validate_wreq_cors_response(
|
||||
request: &ResourceRequest,
|
||||
target: &Url,
|
||||
serialized_origin: &str,
|
||||
headers: &wreq::header::HeaderMap,
|
||||
) -> Result<(), ObscuraNetError> {
|
||||
if !cors_required(request, target) {
|
||||
return Ok(());
|
||||
}
|
||||
let allow_origin =
|
||||
wreq_response_header_value(headers, "access-control-allow-origin", target)?;
|
||||
let allow_credentials =
|
||||
wreq_response_header_value(headers, "access-control-allow-credentials", target)?;
|
||||
validate_cors_response(
|
||||
request,
|
||||
target,
|
||||
serialized_origin,
|
||||
allow_origin,
|
||||
allow_credentials,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
async fn read_wreq_body_limited(
|
||||
response: wreq::Response,
|
||||
url: &Url,
|
||||
limit: usize,
|
||||
) -> Result<Vec<u8>, ObscuraNetError> {
|
||||
if response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.is_some_and(|length| length > limit as u64)
|
||||
{
|
||||
return Err(response_too_large(url, limit));
|
||||
}
|
||||
|
||||
let capacity = response
|
||||
.content_length()
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
.unwrap_or(0)
|
||||
.min(limit);
|
||||
let stream = response.bytes_stream();
|
||||
futures_util::pin_mut!(stream);
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| {
|
||||
ObscuraNetError::Network(format!("Failed to read body: {}", error))
|
||||
})?;
|
||||
if chunk.len() > limit.saturating_sub(body.len()) {
|
||||
return Err(response_too_large(url, limit));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
pub struct StealthHttpClient {
|
||||
client: wreq::Client,
|
||||
@@ -108,6 +195,39 @@ impl StealthHttpClient {
|
||||
}
|
||||
|
||||
pub async fn fetch(&self, url: &Url) -> Result<Response, ObscuraNetError> {
|
||||
self.fetch_with_callbacks(url, None).await
|
||||
}
|
||||
|
||||
pub async fn fetch_with_callbacks(
|
||||
&self,
|
||||
url: &Url,
|
||||
callbacks: Option<&CallbackRegistry>,
|
||||
) -> Result<Response, ObscuraNetError> {
|
||||
self.fetch_with_profile(url, ResourceRequest::navigation(), callbacks)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_resource_with_callbacks(
|
||||
&self,
|
||||
url: &Url,
|
||||
request: ResourceRequest,
|
||||
callbacks: Option<&CallbackRegistry>,
|
||||
) -> Result<Response, ObscuraNetError> {
|
||||
self.fetch_with_profile(url, request, callbacks).await
|
||||
}
|
||||
|
||||
async fn fetch_with_profile(
|
||||
&self,
|
||||
url: &Url,
|
||||
request: ResourceRequest,
|
||||
callbacks: Option<&CallbackRegistry>,
|
||||
) -> Result<Response, ObscuraNetError> {
|
||||
validate_url(url, false)?;
|
||||
validate_request_mode(&request, url)?;
|
||||
if url.scheme() == "file" {
|
||||
return fetch_file_url(url, request.max_response_bytes).await;
|
||||
}
|
||||
|
||||
let mut current_url = url.clone();
|
||||
|
||||
if let Some(host) = current_url.host_str() {
|
||||
@@ -124,31 +244,78 @@ impl StealthHttpClient {
|
||||
}
|
||||
|
||||
let mut redirects = Vec::new();
|
||||
let mut redirect_tainted = false;
|
||||
let mut request_callback_fired = false;
|
||||
|
||||
for _ in 0..20 {
|
||||
validate_request_mode(&request, ¤t_url)?;
|
||||
let mut req = self.client.get(current_url.as_str());
|
||||
|
||||
let cookie_header = self.cookie_jar.get_cookie_header(¤t_url);
|
||||
req = req
|
||||
.header("accept", request.accept())
|
||||
.header("sec-fetch-site", request_fetch_site(&request, ¤t_url))
|
||||
.header("sec-fetch-mode", request.mode.header_value())
|
||||
.header("sec-fetch-dest", request.destination());
|
||||
if request.mode == RequestMode::Navigate {
|
||||
req = req
|
||||
.header("upgrade-insecure-requests", "1")
|
||||
.header("sec-fetch-user", "?1");
|
||||
}
|
||||
if let Some(referer) = request_referrer(&request, ¤t_url) {
|
||||
req = req.header("referer", referer);
|
||||
}
|
||||
let request_origin = serialized_request_origin(&request, redirect_tainted);
|
||||
|
||||
let cookie_header = if request.sends_credentials_to(¤t_url) {
|
||||
self.cookie_jar.get_cookie_header(¤t_url)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !cookie_header.is_empty() {
|
||||
req = req.header("Cookie", &cookie_header);
|
||||
}
|
||||
|
||||
for (k, v) in self.extra_headers.read().await.iter() {
|
||||
if k.eq_ignore_ascii_case("origin") {
|
||||
continue;
|
||||
}
|
||||
req = req.header(k.as_str(), v.as_str());
|
||||
}
|
||||
if cors_required(&request, ¤t_url) {
|
||||
req = req.header("origin", &request_origin);
|
||||
}
|
||||
|
||||
self.in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let request_info = RequestInfo {
|
||||
url: current_url.clone(),
|
||||
method: "GET".to_string(),
|
||||
headers: self.extra_headers.read().await.clone(),
|
||||
resource_type: request.resource_type,
|
||||
};
|
||||
if !request_callback_fired {
|
||||
if let Some(callbacks) = callbacks {
|
||||
callbacks.fire_request(&request_info).await;
|
||||
}
|
||||
request_callback_fired = true;
|
||||
}
|
||||
|
||||
let in_flight = InFlightGuard::new(&self.in_flight);
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
ObscuraNetError::Network(format!("{}: {} (source: {:?})", current_url, e, e.source()))
|
||||
})?;
|
||||
self.in_flight.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let status = resp.status();
|
||||
validate_wreq_cors_response(
|
||||
&request,
|
||||
¤t_url,
|
||||
&request_origin,
|
||||
resp.headers(),
|
||||
)?;
|
||||
|
||||
for val in resp.headers().get_all("set-cookie") {
|
||||
if let Ok(s) = val.to_str() {
|
||||
self.cookie_jar.set_cookie(s, ¤t_url);
|
||||
if request.sends_credentials_to(¤t_url) {
|
||||
for val in resp.headers().get_all("set-cookie") {
|
||||
if let Ok(s) = val.to_str() {
|
||||
self.cookie_jar.set_cookie(s, ¤t_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,23 +333,31 @@ impl StealthHttpClient {
|
||||
let next_url = current_url.join(location_str).map_err(|e| {
|
||||
ObscuraNetError::Network(format!("Invalid redirect URL: {}", e))
|
||||
})?;
|
||||
validate_url(&next_url, false)?;
|
||||
validate_request_mode(&request, &next_url)?;
|
||||
redirect_tainted |=
|
||||
redirect_taints_origin(&request, ¤t_url, &next_url);
|
||||
redirects.push(current_url.clone());
|
||||
current_url = next_url;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let body = resp.bytes().await.map_err(|e| {
|
||||
ObscuraNetError::Network(format!("Failed to read body: {}", e))
|
||||
})?.to_vec();
|
||||
let body = read_wreq_body_limited(resp, ¤t_url, request.max_response_bytes)
|
||||
.await?;
|
||||
drop(in_flight);
|
||||
|
||||
return Ok(Response {
|
||||
let response = Response {
|
||||
url: current_url,
|
||||
status: status.as_u16(),
|
||||
headers: response_headers,
|
||||
body,
|
||||
redirected_from: redirects,
|
||||
});
|
||||
};
|
||||
if let Some(callbacks) = callbacks {
|
||||
callbacks.fire_response(&request_info, &response).await;
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(ObscuraNetError::TooManyRedirects(url.to_string()))
|
||||
@@ -234,12 +409,10 @@ impl StealthHttpClient {
|
||||
req = req.body(body.to_string());
|
||||
}
|
||||
|
||||
self.in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let in_flight = InFlightGuard::new(&self.in_flight);
|
||||
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();
|
||||
if store_cookies {
|
||||
@@ -254,11 +427,8 @@ impl StealthHttpClient {
|
||||
.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();
|
||||
let resp_body = read_wreq_body_limited(resp, url, 64 * 1024 * 1024).await?;
|
||||
drop(in_flight);
|
||||
|
||||
Ok(Response {
|
||||
url: url.clone(),
|
||||
|
||||
Reference in New Issue
Block a user