use std::net::SocketAddr;
use std::time::Duration;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use thiserror::Error;
use super::{
HTTP_CONNECT_TIMEOUT, is_blocked_ip, trusted_http_hosts_from_env,
validate_outbound_url_with_trust,
};
const LOOPBACK_HOST: &str = "localhost";
type ConnectError = Box<dyn std::error::Error + Send + Sync>;
fn boxed(error: GuardedConnectError) -> ConnectError {
Box::new(error)
}
#[derive(Debug, Error)]
pub enum GuardedConnectError {
#[error("cannot resolve {0}")]
Unresolvable(String),
#[error("host {host} resolves to blocked address {addr}")]
BlockedAddress {
host: String,
addr: std::net::IpAddr,
},
#[error("redirect to {url} refused: {reason}")]
RedirectRefused { url: String, reason: String },
#[error("more than {0} redirects")]
TooManyRedirects(usize),
}
#[derive(Debug, Clone)]
pub struct GuardedClientConfig {
pub trusted_hosts: Vec<String>,
pub allow_loopback: bool,
pub max_redirects: usize,
pub timeout: Option<Duration>,
pub connect_timeout: Duration,
pub user_agent: Option<String>,
}
impl Default for GuardedClientConfig {
fn default() -> Self {
Self {
trusted_hosts: trusted_http_hosts_from_env(),
allow_loopback: true,
max_redirects: DEFAULT_MAX_REDIRECTS,
timeout: Some(super::HTTP_DEFAULT_TIMEOUT),
connect_timeout: HTTP_CONNECT_TIMEOUT,
user_agent: None,
}
}
}
pub const DEFAULT_MAX_REDIRECTS: usize = 3;
impl GuardedClientConfig {
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
#[must_use]
pub const fn with_max_redirects(mut self, max_redirects: usize) -> Self {
self.max_redirects = max_redirects;
self
}
#[must_use]
pub fn with_trusted_hosts(mut self, trusted_hosts: Vec<String>) -> Self {
self.trusted_hosts = trusted_hosts;
self
}
#[must_use]
pub const fn deny_loopback(mut self) -> Self {
self.allow_loopback = false;
self
}
fn allowed_hosts(&self) -> Vec<String> {
let mut allowed: Vec<String> = self
.trusted_hosts
.iter()
.map(|h| h.trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect();
if self.allow_loopback {
allowed.push(LOOPBACK_HOST.to_owned());
}
allowed
}
}
#[derive(Debug, Clone)]
pub struct GuardedResolver {
allowed: Vec<String>,
}
impl GuardedResolver {
#[must_use]
pub fn new(allowed: Vec<String>) -> Self {
Self {
allowed: allowed
.into_iter()
.map(|h| h.to_ascii_lowercase())
.collect(),
}
}
}
impl Resolve for GuardedResolver {
fn resolve(&self, name: Name) -> Resolving {
let host = name.as_str().to_ascii_lowercase();
let exempt = self.allowed.iter().any(|h| h == &host);
Box::pin(async move {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|e| {
tracing::warn!(host = %host, error = %e, "Outbound DNS resolution failed");
boxed(GuardedConnectError::Unresolvable(host.clone()))
})?
.collect();
if addrs.is_empty() {
return Err(boxed(GuardedConnectError::Unresolvable(host)));
}
if !exempt && let Some(blocked) = addrs.iter().find(|a| is_blocked_ip(a.ip())) {
tracing::warn!(
host = %host,
addr = %blocked.ip(),
"Refused outbound connection to blocked address"
);
return Err(boxed(GuardedConnectError::BlockedAddress {
host,
addr: blocked.ip(),
}));
}
let resolved: Addrs = Box::new(addrs.into_iter());
Ok(resolved)
})
}
}
pub fn guarded_client_builder(config: &GuardedClientConfig) -> reqwest::ClientBuilder {
let trusted = config.allowed_hosts();
let resolver = GuardedResolver::new(trusted.clone());
let policy = if config.max_redirects == 0 {
reqwest::redirect::Policy::none()
} else {
guarded_redirect_policy(trusted, config.max_redirects)
};
let mut builder = reqwest::Client::builder()
.dns_resolver(std::sync::Arc::new(resolver))
.redirect(policy)
.connect_timeout(config.connect_timeout);
if let Some(timeout) = config.timeout {
builder = builder.timeout(timeout);
}
if let Some(user_agent) = &config.user_agent {
builder = builder.user_agent(user_agent.clone());
}
builder
}
fn guarded_redirect_policy(
trusted: Vec<String>,
max_redirects: usize,
) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() > max_redirects {
return attempt.error(GuardedConnectError::TooManyRedirects(max_redirects));
}
match validate_outbound_url_with_trust(attempt.url().as_str(), &trusted) {
Ok(_) => attempt.follow(),
Err(e) => {
let refused = GuardedConnectError::RedirectRefused {
url: attempt.url().to_string(),
reason: e.to_string(),
};
tracing::warn!(error = %refused, "Refused outbound redirect");
attempt.error(refused)
},
}
})
}
pub fn guarded_client(config: &GuardedClientConfig) -> reqwest::Result<reqwest::Client> {
guarded_client_builder(config).build()
}