use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use ipnet::IpNet;
#[cfg(feature = "outbound-http")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scheme {
Http,
Https,
}
#[cfg(feature = "outbound-http")]
impl Scheme {
pub fn default_port(self) -> u16 {
match self {
Scheme::Http => 80,
Scheme::Https => 443,
}
}
}
#[cfg(feature = "outbound-http")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowEntry {
pub scheme: Scheme,
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddrVerdict {
Allowed,
BlockedReserved,
BlockedPrivate,
}
#[cfg(feature = "outbound-http")]
#[derive(Debug, Clone)]
pub struct OutboundPolicy {
pub allow: Vec<AllowEntry>,
pub allow_private: Vec<IpNet>,
pub connect_timeout: std::time::Duration,
pub total_timeout: std::time::Duration,
pub max_response_bytes: u64,
pub max_concurrent: u32,
}
#[cfg(feature = "outbound-http")]
impl OutboundPolicy {
pub fn allows(&self, scheme: Scheme, host: &str, port: u16) -> bool {
self.allow
.iter()
.any(|e| e.scheme == scheme && e.port == port && e.host.eq_ignore_ascii_case(host))
}
pub fn classify(&self, ip: IpAddr) -> AddrVerdict {
classify(ip, &self.allow_private)
}
}
#[cfg(feature = "outbound-tcp")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TcpAllowEntry {
pub host: String,
pub port: u16,
}
#[cfg(feature = "outbound-tcp")]
#[derive(Debug, Clone)]
pub struct OutboundTcpPolicy {
pub allow: Vec<TcpAllowEntry>,
pub allow_private: Vec<IpNet>,
pub max_connections: u32,
pub io_deadline: std::time::Duration,
}
#[cfg(feature = "outbound-tcp")]
impl OutboundTcpPolicy {
pub fn allows_name(&self, host: &str) -> bool {
self.allow.iter().any(|e| e.host.eq_ignore_ascii_case(host))
}
pub fn classify(&self, ip: IpAddr) -> AddrVerdict {
classify(ip, &self.allow_private)
}
}
pub fn classify(ip: IpAddr, allow_private: &[IpNet]) -> AddrVerdict {
let canonical = canonicalize(ip);
if is_always_blocked(canonical) {
return AddrVerdict::BlockedReserved;
}
if is_private(canonical) {
return if allow_private.iter().any(|net| net.contains(&canonical)) {
AddrVerdict::Allowed
} else {
AddrVerdict::BlockedPrivate
};
}
AddrVerdict::Allowed
}
fn canonicalize(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return IpAddr::V4(v4);
}
let seg = v6.segments();
if seg[0..6].iter().all(|&s| s == 0) && !v6.is_loopback() && !v6.is_unspecified() {
let [a, b] = seg[6].to_be_bytes();
let [c, d] = seg[7].to_be_bytes();
return IpAddr::V4(Ipv4Addr::new(a, b, c, d));
}
IpAddr::V6(v6)
}
v4 => v4,
}
}
fn is_always_blocked(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(a) => v4_always_blocked(a),
IpAddr::V6(a) => v6_always_blocked(a),
}
}
fn v4_always_blocked(a: Ipv4Addr) -> bool {
let o = a.octets();
a.is_loopback() || a.is_link_local() || a.is_broadcast() || a.is_multicast() || a.is_documentation() || o[0] == 0 || (o[0] == 100 && (o[1] & 0xc0) == 0x40) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || (o[0] == 198 && (o[1] & 0xfe) == 18) || (o[0] & 0xf0) == 240 }
fn is_private(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(a) => a.is_private(), IpAddr::V6(a) => (a.segments()[0] & 0xfe00) == 0xfc00, }
}
fn v6_always_blocked(a: Ipv6Addr) -> bool {
let seg = a.segments();
a.is_loopback() || a.is_unspecified() || a.is_multicast() || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] == 0x2001 && seg[1] == 0x0db8) || (seg[0] == 0x2001 && seg[1] == 0x0000) || (seg[0] == 0x0064 && seg[1] == 0xff9b) || (seg[0] == 0x0100 && seg[1..4].iter().all(|&s| s == 0)) }
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn net(s: &str) -> IpNet {
s.parse().unwrap()
}
#[test]
fn cloud_metadata_is_always_blocked() {
let wide = vec![net("0.0.0.0/0")];
assert_eq!(
classify(ip("169.254.169.254"), &wide),
AddrVerdict::BlockedReserved
);
assert_eq!(
classify(ip("169.254.169.254"), &[]),
AddrVerdict::BlockedReserved
);
}
#[test]
fn loopback_link_local_unspecified_multicast_blocked() {
for s in [
"127.0.0.1",
"127.1.2.3",
"0.0.0.0",
"169.254.0.1",
"224.0.0.1",
"255.255.255.255",
"::1",
"::",
"fe80::1",
"ff02::1",
] {
assert_eq!(classify(ip(s), &[]), AddrVerdict::BlockedReserved, "{s}");
}
}
#[test]
fn other_v4_reserved_ranges_blocked() {
for s in [
"100.64.0.1", "192.0.0.1", "198.18.0.1", "192.0.2.5", "203.0.113.9", "240.0.0.1", ] {
assert_eq!(classify(ip(s), &[]), AddrVerdict::BlockedReserved, "{s}");
}
}
#[test]
fn v4_mapped_and_compat_v6_reclassify_to_embedded_v4() {
assert_eq!(
classify(ip("::ffff:169.254.169.254"), &[]),
AddrVerdict::BlockedReserved
);
assert_eq!(
classify(ip("::ffff:127.0.0.1"), &[]),
AddrVerdict::BlockedReserved
);
assert_eq!(classify(ip("::10.0.0.5"), &[]), AddrVerdict::BlockedPrivate);
assert_eq!(classify(ip("::ffff:8.8.8.8"), &[]), AddrVerdict::Allowed);
}
#[test]
fn nat64_and_teredo_blocked() {
assert_eq!(
classify(ip("64:ff9b::8.8.8.8"), &[]),
AddrVerdict::BlockedReserved
);
assert_eq!(
classify(ip("2001:0:1:2::"), &[]),
AddrVerdict::BlockedReserved
);
assert_eq!(
classify(ip("2001:db8::1"), &[]),
AddrVerdict::BlockedReserved
);
}
#[test]
fn private_blocked_without_optin() {
for s in [
"10.0.0.5",
"172.16.0.1",
"192.168.1.1",
"fc00::1",
"fd12::abcd",
] {
assert_eq!(classify(ip(s), &[]), AddrVerdict::BlockedPrivate, "{s}");
}
}
#[test]
fn private_allowed_only_within_optin_cidr() {
let optin = vec![net("10.1.0.0/16")];
assert_eq!(classify(ip("10.1.2.3"), &optin), AddrVerdict::Allowed);
assert_eq!(
classify(ip("10.2.0.1"), &optin),
AddrVerdict::BlockedPrivate
);
assert_eq!(
classify(ip("192.168.0.1"), &optin),
AddrVerdict::BlockedPrivate
);
}
#[test]
fn optin_never_opens_the_reserved_floor() {
let optin = vec![net("0.0.0.0/0"), net("::/0")];
assert_eq!(
classify(ip("169.254.169.254"), &optin),
AddrVerdict::BlockedReserved
);
assert_eq!(
classify(ip("127.0.0.1"), &optin),
AddrVerdict::BlockedReserved
);
}
#[test]
fn ula_optin() {
let optin = vec![net("fd00::/8")];
assert_eq!(classify(ip("fd12::1"), &optin), AddrVerdict::Allowed);
assert_eq!(classify(ip("fc00::1"), &optin), AddrVerdict::BlockedPrivate);
}
#[test]
fn public_addresses_allowed() {
for s in [
"8.8.8.8",
"1.1.1.1",
"93.184.216.34",
"2606:4700:4700::1111",
] {
assert_eq!(classify(ip(s), &[]), AddrVerdict::Allowed, "{s}");
}
}
#[cfg(feature = "outbound-http")]
fn policy(entries: Vec<AllowEntry>) -> OutboundPolicy {
OutboundPolicy {
allow: entries,
allow_private: vec![],
connect_timeout: std::time::Duration::from_secs(2),
total_timeout: std::time::Duration::from_secs(5),
max_response_bytes: 64 * 1024,
max_concurrent: 8,
}
}
#[cfg(feature = "outbound-http")]
#[test]
fn allowlist_exact_match_case_insensitive_host() {
let p = policy(vec![AllowEntry {
scheme: Scheme::Https,
host: "authz.example.com".into(),
port: 443,
}]);
assert!(p.allows(Scheme::Https, "authz.example.com", 443));
assert!(p.allows(Scheme::Https, "AUTHZ.Example.COM", 443)); assert!(!p.allows(Scheme::Http, "authz.example.com", 443)); assert!(!p.allows(Scheme::Https, "authz.example.com", 8443)); assert!(!p.allows(Scheme::Https, "evil.example.com", 443)); assert!(!p.allows(Scheme::Https, "sub.authz.example.com", 443)); }
#[cfg(feature = "outbound-http")]
#[test]
fn empty_allowlist_denies_everything() {
let p = policy(vec![]);
assert!(!p.allows(Scheme::Https, "authz.example.com", 443));
}
#[cfg(feature = "outbound-tcp")]
#[test]
fn tcp_allows_name_is_case_insensitive_and_exact() {
let p = OutboundTcpPolicy {
allow: vec![TcpAllowEntry {
host: "redis.internal".into(),
port: 6379,
}],
allow_private: vec![],
max_connections: 4,
io_deadline: std::time::Duration::from_secs(5),
};
assert!(p.allows_name("redis.internal"));
assert!(p.allows_name("REDIS.Internal")); assert!(!p.allows_name("evil.internal")); assert!(!p.allows_name("sub.redis.internal")); }
}