use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[derive(Debug, Clone)]
pub enum EgressError {
BadScheme(String),
Userinfo,
NoHost,
ZoneId,
Parse(String),
Resolve(String),
Blocked(String),
TooManyRedirects,
}
impl std::fmt::Display for EgressError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EgressError::BadScheme(s) => write!(f, "disallowed URL scheme '{s}' (only http/https)"),
EgressError::Userinfo => write!(f, "URL must not contain credentials (user:pass@)"),
EgressError::NoHost => write!(f, "URL has no host"),
EgressError::ZoneId => write!(f, "URL host must not contain an IPv6 zone id"),
EgressError::Parse(e) => write!(f, "invalid URL: {e}"),
EgressError::Resolve(e) => write!(f, "could not resolve host: {e}"),
EgressError::Blocked(w) => {
write!(f, "refused fetch to non-public address: {w} (set [server] allow_private_fetch or fetch_allowed_hosts to override)")
}
EgressError::TooManyRedirects => write!(f, "too many redirects"),
}
}
}
fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
let o = ip.octets();
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_unspecified() || o[0] == 0 || (o[0] == 100 && (64..=127).contains(&o[1])) || ip.is_multicast() || o[0] >= 240 }
fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4_mapped() {
return is_forbidden_v4(v4);
}
if let Some(v4) = ip.to_ipv4() {
if !ip.is_loopback() && !ip.is_unspecified() {
return is_forbidden_v4(v4);
}
}
let seg = ip.segments();
ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_forbidden_v4(v4),
IpAddr::V6(v6) => is_forbidden_v6(v6),
}
}
pub fn parse_fetch_url(raw: &str) -> Result<reqwest::Url, EgressError> {
let url = reqwest::Url::parse(raw).map_err(|e| EgressError::Parse(e.to_string()))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(EgressError::BadScheme(other.to_string())),
}
if !url.username().is_empty() || url.password().is_some() {
return Err(EgressError::Userinfo);
}
let host = url.host_str().ok_or(EgressError::NoHost)?;
if host.contains('%') {
return Err(EgressError::ZoneId);
}
Ok(url)
}
pub async fn validate_fetch_target(
url: &reqwest::Url,
allow_private: bool,
allowed_hosts: &[String],
) -> Result<(), EgressError> {
let host = url.host_str().ok_or(EgressError::NoHost)?;
if allowed_hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
return Ok(());
}
if allow_private {
return Ok(());
}
if let Ok(ip) = host.parse::<IpAddr>() {
return if is_forbidden_ip(ip) {
Err(EgressError::Blocked(ip.to_string()))
} else {
Ok(())
};
}
let port = url.port_or_known_default().unwrap_or(80);
let addrs = tokio::net::lookup_host((host, port))
.await
.map_err(|e| EgressError::Resolve(e.to_string()))?;
let mut saw_any = false;
for sa in addrs {
saw_any = true;
if is_forbidden_ip(sa.ip()) {
return Err(EgressError::Blocked(format!("{host} -> {}", sa.ip())));
}
}
if !saw_any {
return Err(EgressError::Resolve(format!("no addresses for {host}")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn v4(s: &str) -> IpAddr {
IpAddr::V4(s.parse().unwrap())
}
fn v6(s: &str) -> IpAddr {
IpAddr::V6(s.parse().unwrap())
}
#[test]
fn blocks_ipv4_internal_ranges() {
for s in [
"127.0.0.1", "127.1.2.3", "10.0.0.1", "172.16.5.4", "172.31.255.255",
"192.168.1.1", "169.254.169.254", "0.0.0.0", "0.1.2.3",
"100.64.0.1", "224.0.0.1", "240.0.0.1", "255.255.255.255", "192.0.2.5",
] {
assert!(is_forbidden_ip(v4(s)), "{s} should be forbidden");
}
}
#[test]
fn allows_public_ipv4() {
for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34", "172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1"] {
assert!(!is_forbidden_ip(v4(s)), "{s} should be allowed");
}
}
#[test]
fn blocks_ipv6_internal_and_mapped() {
for s in ["::1", "::", "fe80::1", "fc00::1", "fd00::1", "2001:db8::1", "ff02::1"] {
assert!(is_forbidden_ip(v6(s)), "{s} should be forbidden");
}
assert!(is_forbidden_ip(v6("::ffff:169.254.169.254")));
assert!(is_forbidden_ip(v6("::ffff:127.0.0.1")));
}
#[test]
fn allows_public_ipv6() {
assert!(!is_forbidden_ip(v6("2606:4700:4700::1111"))); assert!(!is_forbidden_ip(v6("::ffff:8.8.8.8")));
}
#[test]
fn parse_rejects_bad_schemes_and_userinfo() {
assert!(matches!(parse_fetch_url("file:///etc/passwd"), Err(EgressError::BadScheme(_))));
assert!(matches!(parse_fetch_url("gopher://x/"), Err(EgressError::BadScheme(_))));
assert!(matches!(parse_fetch_url("http://user:pass@example.com/"), Err(EgressError::Userinfo)));
assert!(parse_fetch_url("https://example.com/path").is_ok());
}
#[test]
fn parser_canonicalizes_numeric_hosts() {
let u = parse_fetch_url("http://2130706433/").unwrap(); assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
let u = parse_fetch_url("http://0177.0.0.1/").unwrap();
assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
}
#[tokio::test]
async fn validate_blocks_literal_metadata_ip() {
let u = parse_fetch_url("http://169.254.169.254/latest/meta-data/").unwrap();
assert!(matches!(
validate_fetch_target(&u, false, &[]).await,
Err(EgressError::Blocked(_))
));
}
#[tokio::test]
async fn validate_allows_when_opted_out_or_allowlisted() {
let u = parse_fetch_url("http://127.0.0.1:3000/api").unwrap();
assert!(validate_fetch_target(&u, true, &[]).await.is_ok());
assert!(validate_fetch_target(&u, false, &["127.0.0.1".to_string()])
.await
.is_ok());
assert!(validate_fetch_target(&u, false, &[]).await.is_err());
}
}