use std::net::{IpAddr, Ipv4Addr};
use reqwest::Client;
pub const MAX_REDIRECTS: usize = 5;
fn is_disallowed_ipv4(v4: Ipv4Addr) -> bool {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_multicast()
|| o[0] == 0
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40)
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
|| (o[0] == 198 && (o[1] & 0xfe) == 18)
|| o[0] >= 240
}
pub fn is_disallowed_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_disallowed_ipv4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_disallowed_ipv4(v4);
}
let first = v6.segments()[0];
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (first & 0xfe00) == 0xfc00
|| (first & 0xffc0) == 0xfe80
}
}
}
fn unbracket(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host)
}
pub fn hardened_http_client() -> Client {
let policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
if let Some(host) = attempt.url().host_str() {
if let Ok(ip) = unbracket(host).parse::<IpAddr>() {
if is_disallowed_ip(ip) {
return attempt.stop();
}
}
}
attempt.follow()
});
Client::builder()
.redirect(policy)
.build()
.expect("failed to build hardened HTTP client")
}
#[derive(Debug)]
pub enum UrlGuardError {
Parse(String),
Scheme(String),
NoHost,
Resolution(String),
NoAddresses(String),
NonPublic(String),
}
impl std::fmt::Display for UrlGuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UrlGuardError::Parse(m) => write!(f, "invalid URL: {m}"),
UrlGuardError::Scheme(s) => {
write!(
f,
"URL scheme not allowed: {s} (only http/https are supported)"
)
}
UrlGuardError::NoHost => write!(f, "URL has no host"),
UrlGuardError::Resolution(m) => write!(f, "failed to resolve host: {m}"),
UrlGuardError::NoAddresses(h) => write!(f, "host {h} resolved to no addresses"),
UrlGuardError::NonPublic(m) => {
write!(f, "host {m} is a non-public address (SSRF guard)")
}
}
}
}
pub async fn validate_fetch_url(url: &str) -> Result<(), UrlGuardError> {
let parsed =
reqwest::Url::parse(url).map_err(|e| UrlGuardError::Parse(format!("{url}: {e}")))?;
match parsed.scheme() {
"http" | "https" => {}
other => return Err(UrlGuardError::Scheme(other.to_string())),
}
let host = parsed.host_str().ok_or(UrlGuardError::NoHost)?;
let host_bare = unbracket(host);
if let Ok(ip) = host_bare.parse::<IpAddr>() {
if is_disallowed_ip(ip) {
return Err(UrlGuardError::NonPublic(host_bare.to_string()));
}
return Ok(());
}
let port = parsed.port_or_known_default().unwrap_or(0);
let host_owned = host_bare.to_string();
let host_for_lookup = host_owned.clone();
let addrs: Vec<std::net::SocketAddr> = tokio::task::spawn_blocking(move || {
use std::net::ToSocketAddrs;
(host_for_lookup.as_str(), port)
.to_socket_addrs()
.map(|it| it.collect::<Vec<_>>())
})
.await
.map_err(|e| UrlGuardError::Resolution(format!("DNS resolution task failed: {e}")))?
.map_err(|e| UrlGuardError::Resolution(format!("{host_owned}: {e}")))?;
if addrs.is_empty() {
return Err(UrlGuardError::NoAddresses(host_owned));
}
for addr in &addrs {
if is_disallowed_ip(addr.ip()) {
return Err(UrlGuardError::NonPublic(format!(
"{host_owned} resolved to {}",
addr.ip()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_and_metadata_are_disallowed() {
assert!(is_disallowed_ip("127.0.0.1".parse().unwrap()));
assert!(is_disallowed_ip("169.254.169.254".parse().unwrap()));
assert!(is_disallowed_ip("10.0.0.1".parse().unwrap()));
assert!(is_disallowed_ip("192.168.1.1".parse().unwrap()));
assert!(is_disallowed_ip("172.16.0.1".parse().unwrap()));
assert!(is_disallowed_ip("100.64.0.1".parse().unwrap())); assert!(is_disallowed_ip("::1".parse().unwrap()));
assert!(is_disallowed_ip("fe80::1".parse().unwrap()));
assert!(is_disallowed_ip("fc00::1".parse().unwrap()));
assert!(is_disallowed_ip("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn public_addresses_are_allowed() {
assert!(!is_disallowed_ip("8.8.8.8".parse().unwrap()));
assert!(!is_disallowed_ip("1.1.1.1".parse().unwrap()));
assert!(!is_disallowed_ip("2606:4700:4700::1111".parse().unwrap()));
}
#[test]
fn reserved_ipv4_ranges_are_disallowed() {
assert!(is_disallowed_ip("0.1.2.3".parse().unwrap())); assert!(is_disallowed_ip("192.0.0.1".parse().unwrap())); assert!(is_disallowed_ip("198.18.0.1".parse().unwrap())); assert!(is_disallowed_ip("198.19.255.255".parse().unwrap())); assert!(is_disallowed_ip("240.0.0.1".parse().unwrap())); assert!(is_disallowed_ip("255.255.255.255".parse().unwrap())); assert!(is_disallowed_ip("203.0.113.7".parse().unwrap()));
}
#[tokio::test]
async fn rejects_non_http_scheme() {
let err = validate_fetch_url("file:///etc/passwd").await.unwrap_err();
assert!(matches!(err, UrlGuardError::Scheme(_)));
}
#[tokio::test]
async fn rejects_literal_loopback() {
let err = validate_fetch_url("http://127.0.0.1/x").await.unwrap_err();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
}
#[tokio::test]
async fn rejects_literal_metadata() {
let err = validate_fetch_url("http://169.254.169.254/latest/meta-data/")
.await
.unwrap_err();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
}
#[tokio::test]
async fn rejects_localhost_without_duplicate_non_public_message() {
let err = validate_fetch_url("http://localhost/x").await.unwrap_err();
let msg = err.to_string();
assert!(matches!(err, UrlGuardError::NonPublic(_)));
assert_eq!(
msg.matches("non-public address").count(),
1,
"non-public wording should not be duplicated: {msg}"
);
}
}