use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::dns;
const MIN_LEARNED_TTL: u64 = 60;
const MAX_LEARNED_TTL: u64 = 3600;
#[derive(Clone, Copy, Debug)]
enum Cidr {
V4 { network: u32, mask: u32 },
V6 { network: u128, mask: u128 },
}
impl Cidr {
fn parse(spec: &str) -> Option<Self> {
let (addr, prefix) = match spec.trim().split_once('/') {
Some((addr, prefix)) => (addr, Some(prefix.parse::<u8>().ok()?)),
None => (spec.trim(), None),
};
match addr.parse::<IpAddr>().ok()? {
IpAddr::V4(ip) => {
let prefix = prefix.unwrap_or(32);
if prefix > 32 {
return None;
}
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
Some(Self::V4 {
network: u32::from(ip) & mask,
mask,
})
}
IpAddr::V6(ip) => {
let prefix = prefix.unwrap_or(128);
if prefix > 128 {
return None;
}
let mask = if prefix == 0 {
0
} else {
u128::MAX << (128 - prefix)
};
Some(Self::V6 {
network: u128::from(ip) & mask,
mask,
})
}
}
}
fn contains(&self, ip: IpAddr) -> bool {
match (self, ip) {
(Self::V4 { network, mask }, IpAddr::V4(ip)) => (u32::from(ip) & mask) == *network,
(Self::V6 { network, mask }, IpAddr::V6(ip)) => (u128::from(ip) & mask) == *network,
_ => false,
}
}
}
struct AllowList {
cidrs: Vec<Cidr>,
allowed_hosts: Option<Vec<String>>,
learned: Mutex<HashMap<IpAddr, Instant>>,
}
fn allow_private_egress() -> bool {
std::env::var("SMOLVM_EGRESS_ALLOW_PRIVATE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
fn is_reserved_v4(v4: Ipv4Addr) -> bool {
v4.is_loopback() || v4.is_link_local() || v4.is_private() || v4.is_unspecified()
|| v4.is_broadcast()
|| matches!(v4.octets(), [100, b, ..] if (64..=127).contains(&b))
}
fn is_reserved_destination(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_reserved_v4(v4),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xffc0) == 0xfe80 || (v6.segments()[0] & 0xfe00) == 0xfc00 || v6.to_ipv4_mapped().is_some_and(is_reserved_v4)
}
}
}
#[derive(Clone)]
pub struct EgressPolicy {
inner: Option<Arc<AllowList>>,
allow_private: bool,
}
impl EgressPolicy {
pub fn unrestricted() -> Self {
Self {
inner: None,
allow_private: allow_private_egress(),
}
}
pub fn new(allowed_cidrs: Option<&[String]>, allowed_hosts: Option<&[String]>) -> Self {
if allowed_cidrs.is_none() && allowed_hosts.is_none() {
return Self::unrestricted();
}
let cidrs = allowed_cidrs
.unwrap_or(&[])
.iter()
.filter_map(|spec| {
let parsed = Cidr::parse(spec);
if parsed.is_none() {
tracing::warn!(cidr = %spec, "ignoring unparseable egress CIDR");
}
parsed
})
.collect();
let allowed_hosts = allowed_hosts.map(|hosts| {
hosts
.iter()
.filter_map(|h| dns::normalize_hostname(h))
.collect()
});
Self {
inner: Some(Arc::new(AllowList {
cidrs,
allowed_hosts,
learned: Mutex::new(HashMap::new()),
})),
allow_private: allow_private_egress(),
}
}
pub fn from_allowed_cidrs(allowed: Option<&[String]>) -> Self {
Self::new(allowed, None)
}
pub fn is_restricted(&self) -> bool {
self.inner.is_some()
}
pub fn dns_filter_active(&self) -> bool {
self.inner
.as_ref()
.is_some_and(|list| list.allowed_hosts.is_some())
}
pub fn hostname_allowed(&self, hostname: &str) -> bool {
match &self.inner {
None => true,
Some(list) => match &list.allowed_hosts {
None => true,
Some(hosts) => dns::hostname_allowed(hostname, hosts),
},
}
}
pub fn allows(&self, ip: IpAddr) -> bool {
if !self.allow_private && is_reserved_destination(ip) {
return false;
}
match &self.inner {
None => true,
Some(list) => {
if list.cidrs.iter().any(|cidr| cidr.contains(ip)) {
return true;
}
list.learned
.lock()
.map(|learned| {
learned
.get(&ip)
.is_some_and(|expires_at| *expires_at > Instant::now())
})
.unwrap_or(false)
}
}
}
pub fn allows_v4(&self, ip: Ipv4Addr) -> bool {
self.allows(IpAddr::V4(ip))
}
pub fn allows_v6(&self, ip: Ipv6Addr) -> bool {
self.allows(IpAddr::V6(ip))
}
pub fn learn_ip_records(&self, records: &[(IpAddr, u32)]) {
let Some(list) = &self.inner else {
return;
};
let Ok(mut learned) = list.learned.lock() else {
return;
};
let now = Instant::now();
learned.retain(|_, expires_at| *expires_at > now);
for (ip, ttl) in records {
let ttl = u64::from(*ttl).clamp(MIN_LEARNED_TTL, MAX_LEARNED_TTL);
let expires_at = now + Duration::from_secs(ttl);
learned
.entry(*ip)
.and_modify(|existing| *existing = (*existing).max(expires_at))
.or_insert(expires_at);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unrestricted_allows_everything() {
let policy = EgressPolicy::unrestricted();
assert!(!policy.is_restricted());
assert!(policy.allows_v4(Ipv4Addr::new(8, 8, 8, 8)));
assert!(policy.allows_v6("2001:4860:4860::8888".parse().unwrap()));
assert!(policy.hostname_allowed("anything.test"));
assert!(!policy.dns_filter_active());
}
#[test]
fn empty_allowlist_denies_all() {
let policy = EgressPolicy::from_allowed_cidrs(Some(&[]));
assert!(policy.is_restricted());
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v6("2606:4700::1111".parse().unwrap()));
}
#[test]
fn cidr_membership_v4() {
let policy = EgressPolicy::new(Some(&["8.8.8.0/24".into(), "1.1.1.1".into()]), None);
assert!(policy.allows_v4(Ipv4Addr::new(8, 8, 8, 7)));
assert!(policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 2)));
assert!(!policy.allows_v4(Ipv4Addr::new(9, 0, 0, 1)));
}
#[test]
fn hard_floor_denies_metadata_internal_loopback_even_unrestricted() {
let p = EgressPolicy::unrestricted();
assert!(!p.allows_v4(Ipv4Addr::new(169, 254, 169, 254))); assert!(!p.allows_v4(Ipv4Addr::new(10, 0, 0, 4))); assert!(!p.allows_v4(Ipv4Addr::new(127, 0, 0, 1))); assert!(!p.allows_v4(Ipv4Addr::new(192, 168, 1, 1)));
assert!(!p.allows_v4(Ipv4Addr::new(172, 16, 0, 1)));
assert!(!p.allows_v4(Ipv4Addr::new(100, 96, 0, 1))); assert!(p.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(p.allows_v4(Ipv4Addr::new(8, 8, 8, 8)));
}
#[test]
fn hard_floor_overrides_allowlist_and_learned_ips() {
let p = EgressPolicy::new(Some(&["10.0.0.0/8".into()]), None);
assert!(!p.allows_v4(Ipv4Addr::new(10, 0, 0, 4)));
let p2 = EgressPolicy::new(None, Some(&["evil.test".into()]));
let meta = IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254));
p2.learn_ip_records(&[(meta, 300)]);
assert!(!p2.allows(meta));
}
#[test]
fn hard_floor_blocks_ipv4_mapped_v6_bypass() {
let p = EgressPolicy::unrestricted();
let mapped: Ipv6Addr = "::ffff:169.254.169.254".parse().unwrap();
assert!(!p.allows_v6(mapped));
assert!(!p.allows_v6("fe80::1".parse().unwrap()));
assert!(!p.allows_v6("fc00::1".parse().unwrap()));
assert!(p.allows_v6("2606:4700::1111".parse().unwrap()));
}
#[test]
fn cidr_membership_v6() {
let policy =
EgressPolicy::new(Some(&["2606:4700::/32".into(), "2001:db8::1".into()]), None);
assert!(policy.allows_v6("2606:4700::1111".parse().unwrap()));
assert!(policy.allows_v6("2606:4700:ffff::1".parse().unwrap()));
assert!(policy.allows_v6("2001:db8::1".parse().unwrap()));
assert!(!policy.allows_v6("2001:db8::2".parse().unwrap()));
assert!(!policy.allows_v6("2607::1".parse().unwrap()));
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
}
#[test]
fn allow_host_gates_dns_and_learns_ips() {
let policy = EgressPolicy::new(None, Some(&["example.com".into()]));
assert!(policy.dns_filter_active());
assert!(policy.hostname_allowed("example.com"));
assert!(policy.hostname_allowed("www.example.com"));
assert!(!policy.hostname_allowed("evil.test"));
let v4 = IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34));
let v6: IpAddr = "2606:2800:21f:cb07:6820:80da:af6b:8b2c"
.parse::<Ipv6Addr>()
.unwrap()
.into();
assert!(!policy.allows(v4));
assert!(!policy.allows(v6));
policy.learn_ip_records(&[(v4, 300), (v6, 600)]);
assert!(policy.allows(v4));
assert!(policy.allows(v6));
}
#[test]
fn learned_ip_respects_min_ttl() {
let policy = EgressPolicy::new(None, Some(&["example.com".into()]));
let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
policy.learn_ip_records(&[(ip, 1)]);
assert!(policy.allows(ip));
}
#[test]
fn unparseable_cidr_is_skipped_not_panicked() {
let policy = EgressPolicy::new(Some(&["nonsense".into(), "1.1.1.1".into()]), None);
assert!(policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v4(Ipv4Addr::new(2, 2, 2, 2)));
}
#[test]
fn v6_prefix_bounds_checked() {
assert!(Cidr::parse("2001:db8::/129").is_none());
assert!(Cidr::parse("1.2.3.4/33").is_none());
assert!(Cidr::parse("::/0").is_some());
assert!(Cidr::parse("0.0.0.0/0").is_some());
}
}