use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use ipnet::IpNet;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
#[derive(Clone, Debug, Default)]
pub struct NetworkPolicy {
observer: Option<EgressObserver>,
enforce: bool,
allow_localhost: bool,
allow_private: bool,
allow: Vec<IpNet>,
}
#[derive(Clone)]
struct EgressObserver(Arc<dyn Fn(&str) + Send + Sync>);
impl std::fmt::Debug for EgressObserver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EgressObserver")
}
}
impl NetworkPolicy {
pub fn with_denial_observer(mut self, observer: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
self.observer = Some(EgressObserver(observer));
self
}
fn observe_denial(&self, host: &str) {
if let Some(observer) = &self.observer {
observer.0(host);
}
}
pub fn allow_all() -> Self {
Self::default()
}
pub fn client_builder(self: &Arc<Self>) -> reqwest::ClientBuilder {
reqwest::Client::builder().dns_resolver(Arc::new(PolicyResolver::new(Arc::clone(self))))
}
pub fn check_literal_host(&self, url: &str) -> Result<(), String> {
let ip = match url::Url::parse(url)
.ok()
.and_then(|u| u.host().map(|h| h.to_owned()))
{
Some(url::Host::Ipv4(v4)) => IpAddr::V4(v4),
Some(url::Host::Ipv6(v6)) => IpAddr::V6(v6),
_ => return Ok(()),
};
if self.permits(ip) {
Ok(())
} else {
self.observe_denial(&ip.to_string());
Err(format!(
"blocked by network policy: {ip} is private/loopback IP space; \
allow-list it on the server with --allow-ip / --allow-localhost / --allow-private"
))
}
}
pub fn deny_private() -> Self {
Self {
enforce: true,
..Self::default()
}
}
#[must_use]
pub fn allow_localhost(mut self, yes: bool) -> Self {
self.allow_localhost = yes;
self
}
#[must_use]
pub fn allow_private(mut self, yes: bool) -> Self {
self.allow_private = yes;
self
}
#[must_use]
pub fn allow_cidr(mut self, net: IpNet) -> Self {
self.allow.push(net);
self
}
pub fn permits(&self, ip: IpAddr) -> bool {
if !self.enforce {
return true;
}
let ip = normalize(ip);
if self.allow.iter().any(|net| net.contains(&ip)) {
return true;
}
if ip.is_loopback() {
return self.allow_localhost;
}
if is_private(ip) {
return self.allow_private;
}
!is_special(ip)
}
}
#[derive(Debug)]
pub struct PolicyResolver {
policy: Arc<NetworkPolicy>,
}
impl PolicyResolver {
pub fn new(policy: Arc<NetworkPolicy>) -> Self {
Self { policy }
}
}
#[derive(Debug)]
pub(super) struct EgressDenied(pub String);
impl std::fmt::Display for EgressDenied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for EgressDenied {}
impl Resolve for PolicyResolver {
fn resolve(&self, name: Name) -> Resolving {
let policy = self.policy.clone();
Box::pin(async move {
let permitted = policy.resolve_permitted(name.as_str()).await.map_err(
|e| -> Box<dyn std::error::Error + Send + Sync> {
match e {
ResolveFailure::Blocked(message) => Box::new(EgressDenied(message)),
ResolveFailure::Lookup(error) => Box::new(error),
}
},
)?;
Ok(Box::new(permitted.into_iter()) as Addrs)
})
}
}
#[derive(Debug)]
pub enum ResolveFailure {
Blocked(String),
Lookup(std::io::Error),
}
impl NetworkPolicy {
pub async fn resolve_permitted(&self, host: &str) -> Result<Vec<SocketAddr>, ResolveFailure> {
let resolved = tokio::net::lookup_host((host, 0))
.await
.map_err(ResolveFailure::Lookup)?;
let permitted: Vec<SocketAddr> = resolved.filter(|addr| self.permits(addr.ip())).collect();
if permitted.is_empty() {
self.observe_denial(host);
return Err(ResolveFailure::Blocked(blocked_message(host)));
}
Ok(permitted)
}
pub async fn check_url(&self, url: &str) -> Result<(), String> {
self.check_literal_host(url)?;
if !self.enforce {
return Ok(());
}
let Some(host) = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
else {
return Ok(());
};
match self.resolve_permitted(&host).await {
Ok(_) | Err(ResolveFailure::Lookup(_)) => Ok(()),
Err(ResolveFailure::Blocked(message)) => Err(message),
}
}
}
fn blocked_message(host: &str) -> String {
format!(
"blocked by network policy: {host} resolves only to private/loopback IP space; \
allow-list it on the server with --allow-ip / --allow-localhost / --allow-private",
)
}
fn normalize(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
v4 => v4,
}
}
fn is_private(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_private() || is_cgnat(v4),
IpAddr::V6(v6) => is_ula(v6),
}
}
fn is_special(ip: IpAddr) -> bool {
if ip.is_unspecified() {
return true;
}
match ip {
IpAddr::V4(v4) => v4.is_link_local() || v4.is_broadcast(),
IpAddr::V6(v6) => is_v6_link_local(v6),
}
}
fn is_cgnat(v4: Ipv4Addr) -> bool {
let [a, b, ..] = v4.octets();
a == 100 && (64..=127).contains(&b)
}
fn is_ula(v6: Ipv6Addr) -> bool {
(v6.octets()[0] & 0xfe) == 0xfc
}
fn is_v6_link_local(v6: Ipv6Addr) -> bool {
let [a, b, ..] = v6.octets();
a == 0xfe && (b & 0xc0) == 0x80
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn cidr(s: &str) -> IpNet {
s.parse().unwrap()
}
#[test]
fn allow_all_permits_everything() {
let p = NetworkPolicy::allow_all();
for s in ["10.0.0.1", "127.0.0.1", "8.8.8.8", "169.254.169.254", "::1"] {
assert!(p.permits(ip(s)), "{s}");
}
}
#[test]
fn deny_private_blocks_internal_space() {
let p = NetworkPolicy::deny_private();
for s in [
"10.0.0.1",
"172.16.5.9",
"192.168.1.1",
"100.64.0.1", "127.0.0.1", "::1", "169.254.169.254", "fe80::1", "fc00::1", "0.0.0.0", "255.255.255.255", ] {
assert!(!p.permits(ip(s)), "should block {s}");
}
}
#[test]
fn deny_private_permits_public() {
let p = NetworkPolicy::deny_private();
for s in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700::1111"] {
assert!(p.permits(ip(s)), "should permit {s}");
}
}
#[test]
fn ipv4_mapped_ipv6_is_classified_as_v4() {
let p = NetworkPolicy::deny_private();
assert!(
!p.permits(ip("::ffff:10.0.0.1")),
"mapped private must block"
);
assert!(
!p.permits(ip("::ffff:127.0.0.1")),
"mapped loopback must block"
);
assert!(p.permits(ip("::ffff:8.8.8.8")), "mapped public ok");
}
#[test]
fn allow_localhost_toggles_loopback_only() {
let p = NetworkPolicy::deny_private().allow_localhost(true);
assert!(p.permits(ip("127.0.0.1")));
assert!(p.permits(ip("::1")));
assert!(!p.permits(ip("10.0.0.1")), "private still blocked");
}
#[test]
fn allow_private_toggles_private_only() {
let p = NetworkPolicy::deny_private().allow_private(true);
assert!(p.permits(ip("10.0.0.1")));
assert!(p.permits(ip("fc00::1")));
assert!(!p.permits(ip("127.0.0.1")), "loopback still blocked");
assert!(
!p.permits(ip("169.254.169.254")),
"link-local still blocked"
);
}
#[test]
fn allow_cidr_overrides_blocks() {
let p = NetworkPolicy::deny_private().allow_cidr(cidr("10.1.0.0/16"));
assert!(p.permits(ip("10.1.2.3")));
assert!(!p.permits(ip("10.2.0.1")), "outside the allowed range");
let host = NetworkPolicy::deny_private().allow_cidr(cidr("169.254.169.254/32"));
assert!(
host.permits(ip("169.254.169.254")),
"explicit metadata allow"
);
}
#[test]
fn blocked_error_names_the_policy_and_host() {
let msg = blocked_message("internal.example");
assert!(msg.contains("blocked by network policy"), "{msg}");
assert!(msg.contains("internal.example"), "{msg}");
}
#[test]
fn permits_filters_blocked_addrs() {
let p = NetworkPolicy::deny_private();
assert!(!p.permits("10.0.0.1".parse().unwrap()), "rfc1918 blocked");
assert!(p.permits("8.8.8.8".parse().unwrap()), "public allowed");
assert!(!p.permits("127.0.0.1".parse().unwrap()), "loopback blocked");
}
}