use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const BLOCKED_METADATA_HOSTS: &[&str] = &[
"metadata.google.internal",
"metadata.goog",
"metadata",
"instance-data",
"instance-data.ec2.internal",
];
const BLOCKED_METADATA_IPS: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::new(100, 100, 100, 200)), IpAddr::V6(Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)), ];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressError {
InvalidUrl(String),
BlockedAddress { host: String, ip: IpAddr },
BlockedHost(String),
}
impl std::fmt::Display for EgressError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EgressError::InvalidUrl(u) => write!(f, "invalid upstream URL: {}", u),
EgressError::BlockedAddress { host, ip } => {
write!(f, "upstream host {} resolves to blocked address {}", host, ip)
}
EgressError::BlockedHost(h) => write!(f, "upstream host {} is blocked", h),
}
}
}
impl std::error::Error for EgressError {}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UpstreamAllowlist {
#[serde(default)]
pub url_prefixes: Vec<String>,
#[serde(default)]
pub hosts: Vec<String>,
}
impl UpstreamAllowlist {
fn allows(&self, url: &url::Url) -> bool {
let Some(host) = url.host_str() else {
return false;
};
if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
return true;
}
self.url_prefixes.iter().any(|p| url.as_str().starts_with(p))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressDecision {
Allowed,
Blocked(EgressError),
}
#[derive(Debug, Clone, Default)]
pub struct EgressGuard {
allowlist: Option<UpstreamAllowlist>,
}
impl EgressGuard {
pub fn new(allowlist: Option<UpstreamAllowlist>) -> Self {
Self { allowlist }
}
pub fn check_without_dns(&self, target: &str) -> EgressDecision {
let url = match target.parse::<url::Url>() {
Ok(u) => u,
Err(_) => return EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string())),
};
if self.allowlist.as_ref().is_some_and(|a| a.allows(&url)) {
return EgressDecision::Allowed;
}
match url.host() {
Some(url::Host::Domain(domain)) => {
let normalized = domain.trim_end_matches('.').to_ascii_lowercase();
if BLOCKED_METADATA_HOSTS.contains(&normalized.as_str()) {
return EgressDecision::Blocked(EgressError::BlockedHost(normalized));
}
EgressDecision::Allowed
}
Some(url::Host::Ipv4(ip)) => {
if is_blocked_ip(IpAddr::V4(ip)) {
EgressDecision::Blocked(EgressError::BlockedAddress {
host: url.host_str().unwrap_or_default().to_string(),
ip: IpAddr::V4(ip),
})
} else {
EgressDecision::Allowed
}
}
Some(url::Host::Ipv6(ip)) => {
if is_blocked_ip(IpAddr::V6(ip)) {
EgressDecision::Blocked(EgressError::BlockedAddress {
host: url.host_str().unwrap_or_default().to_string(),
ip: IpAddr::V6(ip),
})
} else {
EgressDecision::Allowed
}
}
None => EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string())),
}
}
pub async fn check_with_resolver<R, F>(&self, target: &str, resolver: R) -> EgressDecision
where
R: FnOnce(String) -> F,
F: Future<Output = std::io::Result<Vec<IpAddr>>>,
{
match self.check_without_dns(target) {
blocked @ EgressDecision::Blocked(_) => return blocked,
EgressDecision::Allowed => {}
}
let Ok(url) = target.parse::<url::Url>() else {
return EgressDecision::Blocked(EgressError::InvalidUrl(target.to_string()));
};
if self.allowlist.as_ref().is_some_and(|a| a.allows(&url)) {
return EgressDecision::Allowed;
}
let Some(url::Host::Domain(domain)) = url.host() else {
return EgressDecision::Allowed;
};
let domain = domain.trim_end_matches('.').to_ascii_lowercase();
match resolver(domain).await {
Ok(ips) => {
for ip in ips {
if is_blocked_ip(ip) {
return EgressDecision::Blocked(EgressError::BlockedAddress {
host: url.host_str().unwrap_or_default().to_string(),
ip,
});
}
}
EgressDecision::Allowed
}
Err(e) => EgressDecision::Blocked(EgressError::InvalidUrl(format!(
"{} (DNS resolution failed: {})",
target, e
))),
}
}
pub async fn check(&self, target: &str) -> EgressDecision {
self.check_with_resolver(target, |host| async move {
tokio::task::spawn_blocking(move || {
use std::net::ToSocketAddrs;
Ok((host.as_str(), 0u16).to_socket_addrs()?.map(|sa| sa.ip()).collect::<Vec<_>>())
})
.await
.map_err(|e| std::io::Error::other(e.to_string()))?
})
.await
}
}
pub fn is_blocked_ip(ip: IpAddr) -> bool {
if BLOCKED_METADATA_IPS.contains(&ip) {
return true;
}
match ip {
IpAddr::V4(v4) => is_blocked_ipv4(v4),
IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
if let Some(embedded) = v6.to_ipv4_mapped() {
return is_blocked_ipv4(embedded);
}
if let Some(embedded) = embedded_v4_compat(v6) {
return is_blocked_ipv4(embedded);
}
(v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
fn is_blocked_ipv4(v4: Ipv4Addr) -> bool {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private() || v4.is_link_local() || o[0] == 0 }
fn embedded_v4_compat(v6: Ipv6Addr) -> Option<Ipv4Addr> {
let segs = v6.segments();
if segs[0..5] == [0, 0, 0, 0, 0] && segs[5] == 0 && !(segs[6] == 0 && segs[7] <= 1) {
Some(Ipv4Addr::new(
(segs[6] >> 8) as u8,
segs[6] as u8,
(segs[7] >> 8) as u8,
segs[7] as u8,
))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_ipv4_is_blocked() {
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 10, 1))));
}
#[test]
fn link_local_rfc1918_loopback_blocked() {
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))));
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5))));
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
}
#[test]
fn ipv6_equivalents_blocked() {
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe))));
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254))));
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))));
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1))));
}
#[test]
fn public_ips_allowed() {
assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1))));
assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x2606, 0x2800, 0x220, 0x1, 0x248, 0x1893, 0x25c8, 0x1946
))));
}
#[test]
fn guard_blocks_metadata_url() {
let guard = EgressGuard::new(None);
assert_eq!(
guard.check_without_dns("http://169.254.169.254/latest/meta-data/"),
EgressDecision::Blocked(EgressError::BlockedAddress {
host: "169.254.169.254".to_string(),
ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
})
);
}
#[test]
fn guard_blocks_metadata_hostnames() {
let guard = EgressGuard::new(None);
assert!(matches!(
guard.check_without_dns("http://metadata.google.internal/computeMetadata/v1/"),
EgressDecision::Blocked(EgressError::BlockedHost(_))
));
assert!(matches!(
guard.check_without_dns("http://METADATA.goog/foo"),
EgressDecision::Blocked(EgressError::BlockedHost(_))
));
}
#[test]
fn guard_allows_public_literal() {
let guard = EgressGuard::new(None);
assert_eq!(guard.check_without_dns("https://93.184.216.34/x"), EgressDecision::Allowed);
}
#[test]
fn allowlist_overrides_blocklist() {
let guard = EgressGuard::new(Some(UpstreamAllowlist {
url_prefixes: vec!["http://169.254.169.254/".to_string()],
hosts: Vec::new(),
}));
assert_eq!(
guard.check_without_dns("http://169.254.169.254/latest/meta-data/"),
EgressDecision::Allowed
);
}
#[tokio::test]
async fn dns_rebinding_to_private_is_blocked() {
let guard = EgressGuard::new(None);
let decision = guard
.check_with_resolver("http://evil.example.com/", |host| async move {
assert_eq!(host, "evil.example.com");
Ok(vec![
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
IpAddr::V4(Ipv4Addr::LOCALHOST),
])
})
.await;
assert_eq!(
decision,
EgressDecision::Blocked(EgressError::BlockedAddress {
host: "evil.example.com".to_string(),
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
})
);
}
#[tokio::test]
async fn dns_failure_fails_closed() {
let guard = EgressGuard::new(None);
let decision = guard
.check_with_resolver("http://nx.example.com/", |_host| async move {
Err(std::io::Error::other("nx"))
})
.await;
assert!(matches!(decision, EgressDecision::Blocked(EgressError::InvalidUrl(_))));
}
}