#[cfg(any(
feature = "push",
feature = "a2a",
feature = "providers",
feature = "witness-http",
feature = "keyring-vault",
))]
mod resolver;
#[cfg(any(
feature = "push",
feature = "a2a",
feature = "providers",
feature = "witness-http",
feature = "keyring-vault",
))]
pub(crate) use resolver::{Reach, guarded_client};
#[cfg(any(feature = "push", feature = "a2a"))]
pub(crate) use resolver::judge;
#[cfg(any(
feature = "providers",
feature = "a2a",
feature = "media",
feature = "witness-http",
feature = "keyring-vault",
))]
pub mod intake;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[cfg(any(
feature = "push",
feature = "a2a",
feature = "providers",
feature = "witness-http",
feature = "keyring-vault",
feature = "media",
))]
#[must_use]
pub(crate) fn transport_text(error: &reqwest::Error) -> String {
let mut text = error.to_string();
let mut cause = std::error::Error::source(error);
while let Some(inner) = cause {
text.push_str(": ");
text.push_str(&inner.to_string());
cause = inner.source();
}
if let Some(url) = error.url() {
let host = url.host_str().unwrap_or("?");
text = text
.replace(&format!(" for url ({url})"), &format!(" to {host}"))
.replace(url.as_str(), host);
}
text
}
#[cfg(feature = "push")]
#[must_use]
pub(crate) fn host_of(url: &str) -> String {
reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "?".to_owned())
}
#[must_use]
pub fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_v4(ip),
IpAddr::V6(ip) => is_public_v6(ip),
}
}
fn is_public_v4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
!(a == 0
|| a == 10
|| a == 127
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 192 && b == 88 && c == 99)
|| (a == 192 && b == 168)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| a >= 224)
}
fn is_public_v6(ip: Ipv6Addr) -> bool {
let segments = ip.segments();
if let Some(mapped) = ip.to_ipv4_mapped() {
return is_public_v4(mapped);
}
!(ip.is_unspecified()
|| ip.is_loopback()
|| (segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80
|| (segments[0] & 0xffc0) == 0xfec0
|| (segments[0] & 0xff00) == 0xff00
|| segments[0] == 0
|| (segments[0] == 0x0064 && segments[1] == 0xff9b)
|| (segments[0] == 0x0100 && segments[1] == 0)
|| (segments[0] == 0x2001 && segments[1] <= 0x01ff)
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|| segments[0] == 0x2002
|| (segments[0] & 0xfff0) == 0x3ff0
|| segments[0] == 0x5f00)
}
#[must_use]
pub fn is_loopback_name(host: &str) -> bool {
if host == "localhost" {
return true;
}
let bare = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
bare.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
#[cfg(any(feature = "media", feature = "push"))]
#[must_use]
pub fn canonical_host(raw: &str) -> Option<String> {
let url = reqwest::Url::parse(&format!("https://{}/", raw.trim())).ok()?;
if !url.username().is_empty()
|| url.password().is_some()
|| url.port().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return None;
}
Some(url.host_str()?.trim_end_matches('.').to_ascii_lowercase())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetGuardError {
#[error("DNS for '{host}' returned no addresses")]
NoAddresses { host: String },
#[error("'{host}' resolved to forbidden address {address}")]
Forbidden { host: String, address: IpAddr },
}
pub fn all_public<I>(host: &str, addresses: I) -> Result<Vec<std::net::SocketAddr>, NetGuardError>
where
I: IntoIterator<Item = std::net::SocketAddr>,
{
let addresses: Vec<_> = addresses.into_iter().collect();
if addresses.is_empty() {
return Err(NetGuardError::NoAddresses {
host: host.to_owned(),
});
}
for address in &addresses {
if !is_public_ip(address.ip()) {
return Err(NetGuardError::Forbidden {
host: host.to_owned(),
address: address.ip(),
});
}
}
let mut unique = std::collections::BTreeSet::new();
unique.extend(addresses);
Ok(unique.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
type Edge = (&'static str, &'static str, &'static str);
fn assert_edges(edges: &[Edge]) {
for &(refused, permitted, why) in edges {
assert!(
!is_public_ip(refused.parse().unwrap()),
"{refused} was treated as publicly routable ({why})"
);
assert!(
is_public_ip(permitted.parse().unwrap()),
"{permitted} was refused, so the rule for {why} reaches further \
than it should — the guard is refusing part of the internet"
);
}
}
#[test]
fn every_ipv4_range_is_refused_and_its_neighbour_is_not() {
assert_edges(&[
("0.1.2.3", "1.0.0.1", "0.0.0.0/8 — 'this network'"),
("10.255.255.254", "11.0.0.1", "10/8 private"),
("127.255.255.254", "128.0.0.1", "127/8 loopback"),
(
"100.64.0.1",
"100.63.255.254",
"100.64/10 CGNAT, lower edge",
),
(
"100.127.255.254",
"100.128.0.1",
"100.64/10 CGNAT, upper edge",
),
(
"169.254.169.254",
"169.253.0.1",
"link-local — cloud metadata",
),
("169.254.0.1", "169.255.0.1", "link-local, upper edge"),
("172.16.0.1", "172.15.0.1", "172.16/12 private, lower edge"),
(
"172.31.255.254",
"172.32.0.1",
"172.16/12 private, upper edge",
),
(
"192.0.0.1",
"192.0.1.1",
"192.0.0/24 IETF protocol assignments",
),
("192.0.2.1", "192.0.3.1", "192.0.2/24 TEST-NET-1"),
(
"192.88.99.1",
"192.88.100.1",
"192.88.99/24 6to4 relay anycast",
),
(
"192.168.1.1",
"192.167.0.1",
"192.168/16 private, lower edge",
),
(
"192.168.255.254",
"192.169.0.1",
"192.168/16 private, upper edge",
),
(
"198.18.0.1",
"198.17.0.1",
"198.18/15 benchmarking, lower edge",
),
(
"198.19.255.254",
"198.20.0.1",
"198.18/15 benchmarking, upper edge",
),
("198.51.100.1", "198.51.101.1", "198.51.100/24 TEST-NET-2"),
("203.0.113.1", "203.0.114.1", "203.0.113/24 TEST-NET-3"),
("224.0.0.1", "223.255.255.254", "224/4 multicast and above"),
("255.255.255.255", "223.255.255.254", "broadcast"),
]);
}
#[test]
fn every_ipv6_range_is_refused_and_its_neighbour_is_not() {
assert_edges(&[
("::", "1::1", "unspecified, and ::/16 generally"),
("::2", "1::1", "::/16 — includes IPv4-compatible v6"),
("::1", "1::1", "loopback"),
(
"::ffff:127.0.0.1",
"::ffff:1.1.1.1",
"IPv4-mapped is judged as v4",
),
("64:ff9b::1", "64:ff9c::1", "64:ff9b::/32 NAT64 well-known"),
("64:ff9b:1::1", "65::1", "64:ff9b:1::/48 local-use NAT64"),
("100::1", "101::1", "100::/64 discard-only"),
(
"2001::1",
"2001:200::1",
"2001::/23 protocol assignments, lower",
),
("2001:1ff::1", "2001:200::1", "2001::/23 upper edge"),
("2001:db8::1", "2001:db9::1", "2001:db8::/32 documentation"),
("2002::1", "2003::1", "2002::/16 6to4"),
("3ffe::1", "3fe0::1", "3ff0::/12, lower edge"),
("3fff::1", "3fe0::1", "3ff0::/12, upper edge"),
("5f00::1", "5f01::1", "5f00::/16 segment routing"),
("fc00::1", "fe00::1", "fc00::/7 unique-local, lower edge"),
("fdff::1", "fe00::1", "fc00::/7 unique-local, upper edge"),
("fe80::1", "fe40::1", "fe80::/10 link-local, lower edge"),
("febf::1", "fe40::1", "fe80::/10 link-local, upper edge"),
("fec0::1", "fe00::1", "fec0::/10 site-local, lower edge"),
("feff::1", "fe00::1", "fec0::/10 site-local, upper edge"),
("ff02::1", "fe00::1", "ff00::/8 multicast"),
]);
}
#[test]
fn ordinary_public_addresses_are_permitted() {
for addr in ["1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
assert!(
is_public_ip(addr.parse().unwrap()),
"{addr} was refused, so the guard refuses the internet"
);
}
}
#[test]
fn one_private_answer_refuses_the_whole_resolution() {
let addrs = [
"1.1.1.1:443".parse().unwrap(),
"127.0.0.1:443".parse().unwrap(),
];
assert!(
all_public("rebind.example", addrs).is_err(),
"a resolution containing a private address was accepted because \
another answer was public"
);
}
}