use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::sync::{Arc, OnceLock};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
static DEFAULT_EGRESS_POLICY: OnceLock<EgressPolicy> = OnceLock::new();
pub fn set_default_egress_policy(policy: EgressPolicy) -> Result<(), EgressPolicy> {
DEFAULT_EGRESS_POLICY.set(policy)
}
pub fn default_egress_policy() -> EgressPolicy {
DEFAULT_EGRESS_POLICY
.get()
.copied()
.unwrap_or_else(EgressPolicy::default)
}
#[derive(Debug, Clone, Copy)]
pub struct EgressPolicy {
block_link_local: bool,
block_cloud_metadata: bool,
block_loopback: bool,
block_private: bool,
}
impl Default for EgressPolicy {
fn default() -> Self {
Self {
block_link_local: true,
block_cloud_metadata: true,
block_loopback: false,
block_private: false,
}
}
}
impl EgressPolicy {
pub fn permissive() -> Self {
Self {
block_link_local: false,
block_cloud_metadata: false,
block_loopback: false,
block_private: false,
}
}
pub fn strict() -> Self {
Self {
block_link_local: true,
block_cloud_metadata: true,
block_loopback: true,
block_private: true,
}
}
pub fn with_block_link_local(mut self, block: bool) -> Self {
self.block_link_local = block;
self
}
pub fn with_block_cloud_metadata(mut self, block: bool) -> Self {
self.block_cloud_metadata = block;
self
}
pub fn with_block_loopback(mut self, block: bool) -> Self {
self.block_loopback = block;
self
}
pub fn with_block_private(mut self, block: bool) -> Self {
self.block_private = block;
self
}
pub fn permit_ip(&self, ip: IpAddr) -> Result<(), EgressDenial> {
match ip {
IpAddr::V4(v4) => {
if self.block_link_local && v4.is_link_local() {
return Err(EgressDenial::LinkLocal(ip));
}
if self.block_loopback && v4.is_loopback() {
return Err(EgressDenial::Loopback(ip));
}
if self.block_private && v4.is_private() {
return Err(EgressDenial::Private(ip));
}
if self.block_link_local
&& (v4.is_broadcast() || v4.is_multicast() || v4.is_unspecified())
{
return Err(EgressDenial::LinkLocal(ip));
}
}
IpAddr::V6(v6) => {
let segs = v6.segments();
if self.block_link_local && (segs[0] & 0xffc0) == 0xfe80 {
return Err(EgressDenial::LinkLocal(ip));
}
if self.block_cloud_metadata && is_known_cloud_metadata_v6(v6) {
return Err(EgressDenial::CloudMetadata(ip));
}
if self.block_loopback && v6.is_loopback() {
return Err(EgressDenial::Loopback(ip));
}
if self.block_private && (segs[0] & 0xfe00) == 0xfc00 {
return Err(EgressDenial::Private(ip));
}
if self.block_link_local && (v6.is_multicast() || v6.is_unspecified()) {
return Err(EgressDenial::LinkLocal(ip));
}
if let Some(v4) = v6.to_ipv4_mapped() {
return self.permit_ip(IpAddr::V4(v4));
}
}
}
Ok(())
}
}
fn is_known_cloud_metadata_v6(v6: Ipv6Addr) -> bool {
v6 == Ipv6Addr::new(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)
}
#[derive(Debug, Clone)]
pub enum EgressDenial {
LinkLocal(IpAddr),
CloudMetadata(IpAddr),
Loopback(IpAddr),
Private(IpAddr),
}
impl std::fmt::Display for EgressDenial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LinkLocal(ip) => {
write!(f, "egress policy denied link-local address {ip}")
}
Self::CloudMetadata(ip) => {
write!(f, "egress policy denied cloud-metadata address {ip}")
}
Self::Loopback(ip) => write!(f, "egress policy denied loopback address {ip}"),
Self::Private(ip) => write!(f, "egress policy denied private address {ip}"),
}
}
}
impl std::error::Error for EgressDenial {}
pub struct EgressFilteredResolver {
policy: EgressPolicy,
}
impl EgressFilteredResolver {
pub fn new(policy: EgressPolicy) -> Self {
Self { policy }
}
pub fn into_dns_resolver(self) -> Arc<Self> {
Arc::new(self)
}
}
impl Resolve for EgressFilteredResolver {
fn resolve(&self, name: Name) -> Resolving {
let policy = self.policy;
let host = name.as_str().to_string();
Box::pin(async move {
let lookup_target = format!("{host}:0");
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(lookup_target)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.collect();
let mut allowed: Vec<SocketAddr> = Vec::with_capacity(resolved.len());
let mut first_denial: Option<EgressDenial> = None;
for sa in resolved {
match policy.permit_ip(sa.ip()) {
Ok(()) => allowed.push(sa),
Err(denial) => {
if first_denial.is_none() {
first_denial = Some(denial);
}
}
}
}
if allowed.is_empty() {
let message: String = match first_denial {
Some(d) => d.to_string(),
None => format!("no addresses resolved for '{host}'"),
};
return Err(Box::<dyn std::error::Error + Send + Sync>::from(message));
}
let addrs: Addrs = Box::new(allowed.into_iter());
Ok(addrs)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}
#[allow(clippy::too_many_arguments)]
fn v6(s0: u16, s1: u16, s2: u16, s3: u16, s4: u16, s5: u16, s6: u16, s7: u16) -> IpAddr {
IpAddr::V6(Ipv6Addr::new(s0, s1, s2, s3, s4, s5, s6, s7))
}
#[test]
fn default_blocks_link_local_and_cloud_metadata() {
let p = EgressPolicy::default();
assert!(matches!(
p.permit_ip(v4(169, 254, 169, 254)),
Err(EgressDenial::LinkLocal(_))
));
assert!(matches!(
p.permit_ip(v6(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
Err(EgressDenial::LinkLocal(_))
));
assert!(matches!(
p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)),
Err(EgressDenial::CloudMetadata(_))
));
}
#[test]
fn default_allows_loopback_and_private() {
let p = EgressPolicy::default();
assert!(p.permit_ip(v4(127, 0, 0, 1)).is_ok());
assert!(p.permit_ip(v4(10, 0, 0, 1)).is_ok());
assert!(p.permit_ip(v4(192, 168, 1, 1)).is_ok());
assert!(p.permit_ip(v4(8, 8, 8, 8)).is_ok());
assert!(p.permit_ip(v6(0, 0, 0, 0, 0, 0, 0, 1)).is_ok());
}
#[test]
fn strict_blocks_loopback_and_private() {
let p = EgressPolicy::strict();
assert!(matches!(
p.permit_ip(v4(127, 0, 0, 1)),
Err(EgressDenial::Loopback(_))
));
assert!(matches!(
p.permit_ip(v4(10, 0, 0, 1)),
Err(EgressDenial::Private(_))
));
assert!(matches!(
p.permit_ip(v6(0xfc00, 0, 0, 0, 0, 0, 0, 1)),
Err(EgressDenial::Private(_))
));
assert!(p.permit_ip(v4(8, 8, 8, 8)).is_ok());
}
#[test]
fn permissive_allows_everything() {
let p = EgressPolicy::permissive();
assert!(p.permit_ip(v4(169, 254, 169, 254)).is_ok());
assert!(p.permit_ip(v4(127, 0, 0, 1)).is_ok());
assert!(p.permit_ip(v4(10, 0, 0, 1)).is_ok());
assert!(
p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254))
.is_ok()
);
}
#[test]
fn ipv4_mapped_ipv6_inherits_v4_rules() {
let p = EgressPolicy::default();
let mapped = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped();
assert!(matches!(
p.permit_ip(IpAddr::V6(mapped)),
Err(EgressDenial::LinkLocal(_))
));
}
#[test]
fn builder_overrides_individual_categories() {
let p = EgressPolicy::default().with_block_link_local(false);
assert!(p.permit_ip(v4(169, 254, 169, 254)).is_ok());
assert!(matches!(
p.permit_ip(v6(0xfd00, 0x00ec, 0x0002, 0, 0, 0, 0, 0x0254)),
Err(EgressDenial::CloudMetadata(_))
));
}
#[tokio::test(flavor = "multi_thread")]
async fn filtered_resolver_denies_link_local_lookup() {
let resolver = EgressFilteredResolver::new(EgressPolicy::default());
let name: reqwest::dns::Name = "169.254.169.254".parse().unwrap();
let result = resolver.resolve(name).await;
let err = match result {
Ok(_) => panic!("policy must deny link-local literal"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("link-local"),
"expected link-local denial, got: {msg}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn filtered_resolver_permits_public_lookup() {
let resolver = EgressFilteredResolver::new(EgressPolicy::default());
let name: reqwest::dns::Name = "8.8.8.8".parse().unwrap();
if resolver.resolve(name).await.is_err() {
panic!("public IP must be permitted by default policy");
}
}
}