use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use reqwest::Url;
pub async fn validate_destination(url: &Url) -> Result<Vec<SocketAddr>, String> {
if !matches!(url.scheme(), "http" | "https") {
return Err("URL must use http:// or https://".to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("URLs containing credentials are not allowed".to_string());
}
let host = url
.host_str()
.ok_or_else(|| "URL must include a host".to_string())?;
let normalized_host = host.trim_end_matches('.').to_ascii_lowercase();
if normalized_host == "localhost"
|| normalized_host.ends_with(".localhost")
|| normalized_host.ends_with(".local")
|| normalized_host.ends_with(".internal")
{
return Err(format!("Private destination is not allowed: {host}"));
}
let port = url
.port_or_known_default()
.ok_or_else(|| "URL has no usable port".to_string())?;
let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
.await
.map_err(|e| format!("Failed to resolve {host}: {e}"))?
.collect();
if addresses.is_empty() {
return Err(format!("Host did not resolve: {host}"));
}
if let Some(address) = addresses.iter().find(|addr| !is_public_ip(addr.ip())) {
return Err(format!(
"Private or non-public destination is not allowed: {}",
address.ip()
));
}
Ok(addresses)
}
fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_ipv4(ip),
IpAddr::V6(ip) => is_public_ipv6(ip),
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, d] = ip.octets();
!matches!(
(a, b, c, d),
(0, _, _, _)
| (10, _, _, _)
| (100, 64..=127, _, _)
| (127, _, _, _)
| (169, 254, _, _)
| (172, 16..=31, _, _)
| (192, 0, 0, _)
| (192, 0, 2, _)
| (192, 168, _, _)
| (198, 18..=19, _, _)
| (198, 51, 100, _)
| (203, 0, 113, _)
| (224..=255, _, _, _)
)
}
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
let segments = ip.segments();
if ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_multicast()
|| (segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
{
return false;
}
if let Some(ipv4) = ip.to_ipv4_mapped() {
return is_public_ipv4(ipv4);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_public_ipv4_ranges() {
for ip in [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"192.168.1.1",
"224.0.0.1",
] {
assert!(!is_public_ip(ip.parse().unwrap()), "{ip}");
}
assert!(is_public_ip("8.8.8.8".parse().unwrap()));
}
#[test]
fn rejects_non_public_ipv6_ranges() {
for ip in [
"::",
"::1",
"fc00::1",
"fe80::1",
"2001:db8::1",
"::ffff:127.0.0.1",
] {
assert!(!is_public_ip(ip.parse().unwrap()), "{ip}");
}
assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap()));
}
#[tokio::test]
async fn rejects_loopback_literal_before_connecting() {
let url = Url::parse("http://127.0.0.1/admin").unwrap();
let error = validate_destination(&url).await.unwrap_err();
assert!(error.contains("non-public"));
}
#[tokio::test]
async fn rejects_localhost_name_before_resolving() {
let url = Url::parse("http://localhost/admin").unwrap();
let error = validate_destination(&url).await.unwrap_err();
assert!(error.contains("Private destination"));
}
}