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 ResponseHead {
pub final_url: String,
pub status: u16,
pub headers: Vec<(String, String)>,
pub redirects: usize,
}
impl ResponseHead {
fn of(response: &reqwest::Response, redirects: usize) -> Self {
Self {
final_url: response.url().to_string(),
status: response.status().as_u16(),
headers: response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_owned(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect(),
redirects,
}
}
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 FetchedResponse {
pub head: ResponseHead,
pub body: String,
}
#[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 head = ResponseHead::of(&response, redirects);
let body = self.read_body(response).await?;
Ok(FetchedResponse { head, body })
}
pub async fn get_head(&self, url: &str) -> Result<ResponseHead> {
let (response, redirects) = self.send(url).await?;
Ok(ResponseHead::of(&response, 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()))?;
let location = response
.status()
.is_redirection()
.then(|| response.headers().get(reqwest::header::LOCATION))
.flatten();
let Some(location) = location else {
return Ok((response, hop));
};
let location = location.to_str().map_err(|_| {
SeerError::HttpError("redirect location is not valid text".to_string())
})?;
url = url
.join(location)
.or_else(|_| Url::parse(location))
.map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
}
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.head.status, 200);
assert_eq!(resp.body, "hello world");
assert_eq!(resp.head.redirects, 0);
assert_eq!(resp.head.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.head.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.head.status, 200);
assert_eq!(resp.body, "arrived");
assert_eq!(resp.head.redirects, 1);
assert!(
resp.head.final_url.ends_with("/end"),
"got {}",
resp.head.final_url
);
}
#[tokio::test]
async fn a_redirect_status_without_location_is_final() {
let server = MockServer::start().await;
for status in [300u16, 304] {
Mock::given(method("GET"))
.and(path(format!("/s{status}")))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let head = GuardedFetcher::new()
.allowing_private_hosts()
.get_head(&format!("{}/s{status}", server.uri()))
.await
.unwrap();
assert_eq!(head.status, status);
assert_eq!(head.redirects, 0);
}
}
#[tokio::test]
async fn get_head_does_not_wait_for_the_body() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut tcp, _) = listener.accept().await.unwrap();
let _ = tcp.read(&mut [0u8; 1024]).await;
let _ = tcp
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\nx-probe: 1\r\n\r\n")
.await;
std::future::pending::<()>().await;
});
let fetcher = GuardedFetcher::new()
.allowing_private_hosts()
.with_timeout(Duration::from_millis(500));
let head = fetcher.get_head(&format!("http://{addr}/")).await.unwrap();
assert_eq!(head.status, 200);
assert_eq!(head.header("x-probe"), Some("1"));
}
#[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}");
}
}