use std::collections::HashSet;
use std::time::Duration;
use futures::StreamExt;
use reqwest::Url;
use crate::error::{Result, SeerError};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REDIRECTS: usize = 5;
const DEFAULT_MAX_BODY: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct FetchedResponse {
pub final_url: String,
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
pub redirects: usize,
}
impl FetchedResponse {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
pub fn header_all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> {
self.headers
.iter()
.filter(move |(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone)]
pub(crate) struct GuardedFetcher {
timeout: Duration,
max_body: usize,
allow_private: bool,
}
impl GuardedFetcher {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
max_body: DEFAULT_MAX_BODY,
allow_private: false,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_max_body(mut self, max_body: usize) -> Self {
self.max_body = max_body;
self
}
#[cfg(test)]
pub fn allowing_private_hosts(mut self) -> Self {
self.allow_private = true;
self
}
pub async fn get(&self, url: &str) -> Result<FetchedResponse> {
let (response, redirects) = self.send(url).await?;
let status = response.status().as_u16();
let final_url = response.url().to_string();
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_ascii_lowercase(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect();
let body = self.read_body(response).await?;
Ok(FetchedResponse {
final_url,
status,
headers,
body,
redirects,
})
}
pub async fn send(&self, url: &str) -> Result<(reqwest::Response, usize)> {
let mut url = Url::parse(url)
.map_err(|e| SeerError::HttpError(format!("invalid URL '{}': {}", url, e)))?;
let mut visited: HashSet<String> = HashSet::new();
for hop in 0..=MAX_REDIRECTS {
if !visited.insert(url.as_str().to_string()) {
return Err(SeerError::HttpError("redirect loop detected".to_string()));
}
let mut builder =
crate::net::client_builder(self.timeout).user_agent(crate::net::USER_AGENT);
if !self.allow_private {
let addrs = crate::net::validate_http_url(&url).await?;
let host = url
.host_str()
.ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
builder = builder.resolve_to_addrs(host, &addrs);
}
let client = builder
.build()
.map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
let response = client
.get(url.clone())
.send()
.await
.map_err(|e| SeerError::HttpError(e.to_string()))?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
SeerError::HttpError("redirect missing location header".to_string())
})?;
url = url
.join(location)
.or_else(|_| Url::parse(location))
.map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
continue;
}
return Ok((response, hop));
}
Err(SeerError::HttpError("too many redirects".to_string()))
}
pub async fn read_body(&self, response: reqwest::Response) -> Result<String> {
let body = read_body_capped(response, self.max_body, self.timeout, Overflow::Truncate)
.await
.map_err(|e| match e {
BodyReadError::TimedOut => {
SeerError::Timeout(format!("HTTP body read timed out after {:?}", self.timeout))
}
other => SeerError::HttpError(format!("body chunk: {other}")),
})?;
Ok(String::from_utf8_lossy(&body).into_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Overflow {
Truncate,
Reject,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum BodyReadError {
#[error("{0}")]
Chunk(reqwest::Error),
#[error("body exceeds the size cap")]
TooLarge,
#[error("body read timed out")]
TimedOut,
}
pub(crate) async fn read_body_capped(
response: reqwest::Response,
cap: usize,
timeout: Duration,
overflow: Overflow,
) -> std::result::Result<Vec<u8>, BodyReadError> {
let mut body = Vec::new();
let mut stream = response.bytes_stream();
let read = tokio::time::timeout(timeout, async {
while overflow == Overflow::Reject || body.len() < cap {
let Some(chunk) = stream.next().await else {
break;
};
let chunk = chunk.map_err(BodyReadError::Chunk)?;
let room = cap.saturating_sub(body.len());
if chunk.len() <= room {
body.extend_from_slice(&chunk);
} else if overflow == Overflow::Truncate {
body.extend_from_slice(&chunk[..room]);
} else {
return Err(BodyReadError::TooLarge);
}
}
Ok(())
})
.await;
match read {
Ok(Ok(())) => Ok(body),
Ok(Err(e)) => Err(e),
Err(_) => Err(BodyReadError::TimedOut),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn returns_status_headers_and_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("X-Frame-Options", "DENY")
.set_body_string("hello world"),
)
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/", server.uri()))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, "hello world");
assert_eq!(resp.redirects, 0);
assert_eq!(resp.header("x-frame-options"), Some("DENY"));
}
#[tokio::test]
async fn collects_repeated_set_cookie_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.append_header("Set-Cookie", "a=1; Secure")
.append_header("Set-Cookie", "b=2; HttpOnly"),
)
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/", server.uri()))
.await
.unwrap();
let cookies: Vec<&str> = resp.header_all("set-cookie").collect();
assert_eq!(cookies.len(), 2, "both Set-Cookie values must survive");
assert!(cookies.contains(&"a=1; Secure"));
assert!(cookies.contains(&"b=2; HttpOnly"));
}
#[tokio::test]
async fn follows_redirects_and_reports_hop_count() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(ResponseTemplate::new(302).insert_header("location", "/end"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/end"))
.respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/start", server.uri()))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, "arrived");
assert_eq!(resp.redirects, 1);
assert!(resp.final_url.ends_with("/end"), "got {}", resp.final_url);
}
#[tokio::test]
async fn redirect_loop_is_detected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/loop"))
.respond_with(ResponseTemplate::new(302).insert_header("location", "/loop"))
.mount(&server)
.await;
let err = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/loop", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("redirect loop"), "got: {err}");
}
#[tokio::test]
async fn body_is_capped() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/big"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(10_000)))
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.with_max_body(128)
.get(&format!("{}/big", server.uri()))
.await
.unwrap();
assert_eq!(resp.body.len(), 128, "body must be truncated at the cap");
}
#[tokio::test]
async fn rejecting_read_accepts_the_cap_and_refuses_one_byte_more() {
let server = MockServer::start().await;
Mock::given(path("/body"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(100)))
.mount(&server)
.await;
let url = format!("{}/body", server.uri());
let read = |cap| {
let url = url.clone();
async move {
let resp = reqwest::get(url).await.unwrap();
read_body_capped(resp, cap, DEFAULT_TIMEOUT, Overflow::Reject).await
}
};
assert_eq!(read(100).await.unwrap().len(), 100);
assert!(matches!(read(99).await, Err(BodyReadError::TooLarge)));
}
#[tokio::test]
async fn production_client_refuses_loopback() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let err = GuardedFetcher::new()
.get(&format!("{}/", server.uri()))
.await
.unwrap_err();
assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
server.verify().await;
for url in ["http://127.0.0.1/", "https://127.0.0.1/"] {
let err = GuardedFetcher::new().get(url).await.unwrap_err();
assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
assert!(
!err.to_string().contains("non-standard port"),
"{url} must be refused by the reserved-range check: {err}"
);
}
}
#[tokio::test]
async fn production_client_refuses_private_and_metadata_literals() {
let fetcher = GuardedFetcher::new();
for url in [
"http://10.0.0.1/",
"https://169.254.169.254/latest/meta-data",
"http://[::1]/",
] {
let err = fetcher.get(url).await.unwrap_err();
assert!(
matches!(err, SeerError::HttpError(_)),
"{url} must be refused, got {err:?}"
);
}
}
#[tokio::test]
async fn rejects_non_http_scheme_and_nonstandard_port() {
let fetcher = GuardedFetcher::new();
let err = fetcher.get("ftp://example.com/").await.unwrap_err();
assert!(err.to_string().contains("scheme"), "got: {err}");
let err = fetcher.get("http://example.com:8080/").await.unwrap_err();
assert!(err.to_string().contains("port"), "got: {err}");
}
#[tokio::test]
async fn rejects_embedded_credentials() {
let err = GuardedFetcher::new()
.get("https://user:pass@example.com/")
.await
.unwrap_err();
assert!(err.to_string().contains("credentials"), "got: {err}");
}
}