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>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FloorMode {
Off,
MetadataOnly,
Strict,
}
fn parse_floor_override(v: &str) -> Option<FloorMode> {
match v.trim().to_ascii_lowercase().as_str() {
"strict" => Some(FloorMode::Strict),
"metadata" | "metadata-only" | "metadataonly" => Some(FloorMode::MetadataOnly),
"off" | "none" => Some(FloorMode::Off),
_ => None,
}
}
fn floor_mode() -> FloorMode {
if let Ok(v) = std::env::var("SMOLVM_EGRESS_FLOOR") {
if let Some(mode) = parse_floor_override(&v) {
return mode;
}
}
let allow_private = std::env::var("SMOLVM_EGRESS_ALLOW_PRIVATE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if allow_private {
FloorMode::Off
} else if std::env::var_os("SMOLVM_PUBLISH_ADDR").is_some() {
FloorMode::Strict
} else {
FloorMode::MetadataOnly
}
}
fn is_link_local(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
(v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
}
}
}
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_floored(ip: IpAddr, mode: FloorMode) -> bool {
match mode {
FloorMode::Off => false,
FloorMode::MetadataOnly => is_link_local(ip),
FloorMode::Strict => 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>>,
floor: FloorMode,
}
impl EgressPolicy {
pub fn unrestricted() -> Self {
Self {
inner: None,
floor: floor_mode(),
}
}
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()),
})),
floor: floor_mode(),
}
}
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 is_floored(ip, self.floor) {
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 floor_override_parsing() {
assert_eq!(parse_floor_override("strict"), Some(FloorMode::Strict));
assert_eq!(parse_floor_override(" STRICT "), Some(FloorMode::Strict));
assert_eq!(
parse_floor_override("metadata"),
Some(FloorMode::MetadataOnly)
);
assert_eq!(
parse_floor_override("metadata-only"),
Some(FloorMode::MetadataOnly)
);
assert_eq!(parse_floor_override("off"), Some(FloorMode::Off));
assert_eq!(parse_floor_override("none"), Some(FloorMode::Off));
assert_eq!(parse_floor_override(""), None);
assert_eq!(parse_floor_override("yes"), None);
}
#[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 unrestricted_local_floors_only_metadata() {
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))); }
#[test]
fn metadata_floor_overrides_allowlist_and_learned_ips() {
let p = EgressPolicy::new(Some(&["169.254.0.0/16".into()]), None);
assert!(!p.allows_v4(Ipv4Addr::new(169, 254, 169, 254)));
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));
let p3 = EgressPolicy::new(Some(&["10.0.0.0/8".into()]), None);
assert!(p3.allows_v4(Ipv4Addr::new(10, 0, 0, 4)));
}
#[test]
fn metadata_floor_blocks_mapped_and_v6_link_local() {
let p = EgressPolicy::unrestricted(); assert!(!p.allows_v6("::ffff:169.254.169.254".parse().unwrap()));
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());
}
fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}
#[test]
fn floor_off_blocks_nothing() {
for ip in [
v4(8, 8, 8, 8),
v4(169, 254, 169, 254),
v4(192, 168, 1, 5),
v4(127, 0, 0, 1),
] {
assert!(
!is_floored(ip, FloorMode::Off),
"{ip} should not be floored when Off"
);
}
}
#[test]
fn floor_metadata_only_blocks_just_link_local() {
assert!(is_floored(v4(169, 254, 169, 254), FloorMode::MetadataOnly));
assert!(is_floored(v4(169, 254, 0, 1), FloorMode::MetadataOnly));
for ip in [
v4(8, 8, 8, 8),
v4(192, 168, 1, 5),
v4(10, 0, 0, 7),
v4(127, 0, 0, 1),
v4(172, 16, 5, 5),
] {
assert!(
!is_floored(ip, FloorMode::MetadataOnly),
"{ip} should be reachable locally"
);
}
assert!(is_floored(
"::ffff:169.254.169.254".parse().unwrap(),
FloorMode::MetadataOnly
));
}
#[test]
fn floor_strict_blocks_internal_and_metadata() {
for ip in [
v4(169, 254, 169, 254), v4(192, 168, 1, 5), v4(10, 0, 0, 7),
v4(172, 16, 5, 5),
v4(127, 0, 0, 1), v4(100, 64, 0, 1), ] {
assert!(
is_floored(ip, FloorMode::Strict),
"{ip} should be floored under Strict"
);
}
assert!(!is_floored(v4(8, 8, 8, 8), FloorMode::Strict));
assert!(!is_floored(v4(100, 128, 0, 1), FloorMode::Strict));
assert!(is_floored("fe80::1".parse().unwrap(), FloorMode::Strict));
assert!(is_floored("fc00::1".parse().unwrap(), FloorMode::Strict));
assert!(is_floored(
"::ffff:10.0.0.1".parse().unwrap(),
FloorMode::Strict
));
assert!(!is_floored(
"2606:4700::1111".parse().unwrap(),
FloorMode::Strict
));
}
}