use std::sync::LazyLock;
use std::time::Duration;
const DEFAULT_REST_TIMEOUT_SECS: u64 = 30;
const DEFAULT_REST_CONNECT_TIMEOUT_SECS: u64 = 10;
pub(crate) fn rest_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(env_secs("VTA_REST_TIMEOUT_SECS", DEFAULT_REST_TIMEOUT_SECS))
.connect_timeout(env_secs(
"VTA_REST_CONNECT_TIMEOUT_SECS",
DEFAULT_REST_CONNECT_TIMEOUT_SECS,
))
.build()
.expect("reqwest client with timeouts (TLS backend init)")
}
fn env_secs(var: &str, default: u64) -> Duration {
let secs = std::env::var(var)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(default);
Duration::from_secs(secs)
}
const FOREIGN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_MAX_FOREIGN_BODY: usize = 2 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum ForeignFetchError {
#[error("{0}")]
Blocked(String),
#[error("response body exceeds the {max}-byte cap")]
BodyTooLarge { max: usize },
#[error("reading response body failed: {0}")]
Read(String),
}
static FOREIGN_FETCH_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(FOREIGN_FETCH_TIMEOUT)
.connect_timeout(FOREIGN_FETCH_TIMEOUT)
.build()
.expect("hardened foreign-fetch client builds from static config")
});
pub fn foreign_fetch_client() -> reqwest::Client {
FOREIGN_FETCH_CLIENT.clone()
}
pub async fn read_body_capped(
mut resp: reqwest::Response,
max: usize,
) -> Result<Vec<u8>, ForeignFetchError> {
let mut buf = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| ForeignFetchError::Read(e.to_string()))?
{
if buf.len() + chunk.len() > max {
return Err(ForeignFetchError::BodyTooLarge { max });
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
pub fn guard_public_url(url: &str) -> Result<(), ForeignFetchError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ForeignFetchError::Blocked(format!("invalid url {url}: {e}")))?;
if parsed.scheme() != "https" {
return Err(ForeignFetchError::Blocked(format!(
"url must be https (got scheme {})",
parsed.scheme()
)));
}
if parsed.username() != "" || parsed.password().is_some() {
return Err(ForeignFetchError::Blocked(
"url must not contain userinfo".into(),
));
}
let host_str = parsed
.host_str()
.ok_or_else(|| ForeignFetchError::Blocked("url missing host".into()))?;
let host_normalised = host_str
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host_str);
if let Ok(ip) = host_normalised.parse::<std::net::IpAddr>() {
use std::net::IpAddr;
let private = match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.is_multicast()
|| v4.is_documentation()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (v6.segments()[0] & 0xfe00 == 0xfc00) || (v6.segments()[0] & 0xffc0 == 0xfe80) }
};
if private {
return Err(ForeignFetchError::Blocked(format!(
"url points at non-public IP {ip}"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_secs_uses_default_when_unset_or_junk() {
assert_eq!(
env_secs("VTA_REST_TIMEOUT_SECS_DEFINITELY_UNSET_XYZ", 30),
Duration::from_secs(30)
);
}
#[test]
fn rest_client_builds() {
let _ = rest_client();
}
#[test]
fn guard_allows_public_https() {
guard_public_url("https://example.com/status/list").expect("public https ok");
}
#[test]
fn guard_blocks_plain_http() {
guard_public_url("http://example.com/status").expect_err("http blocked");
}
#[test]
fn guard_blocks_loopback() {
guard_public_url("https://127.0.0.1/x").expect_err("loopback blocked");
guard_public_url("https://127.1/x").expect_err("loopback short form blocked");
}
#[test]
fn guard_blocks_private_v4() {
guard_public_url("https://10.0.0.1/x").expect_err("10/8 blocked");
guard_public_url("https://192.168.1.5/x").expect_err("192.168 blocked");
guard_public_url("https://172.16.0.1/x").expect_err("172.16 blocked");
}
#[test]
fn guard_blocks_cloud_metadata() {
guard_public_url("https://169.254.169.254/latest/meta-data/")
.expect_err("link-local metadata blocked");
}
#[test]
fn guard_blocks_v6_internal() {
guard_public_url("https://[::1]/x").expect_err("v6 loopback blocked");
guard_public_url("https://[fc00::1]/x").expect_err("v6 ULA blocked");
guard_public_url("https://[fe80::1]/x").expect_err("v6 link-local blocked");
}
#[test]
fn guard_blocks_userinfo() {
guard_public_url("https://user:pass@example.com/x").expect_err("userinfo blocked");
}
#[test]
fn guard_blocks_garbage() {
guard_public_url("not a url").expect_err("garbage blocked");
}
}