use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
const MAX_REDIRECTS: usize = 10;
#[cfg(test)]
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddressPolicy {
PublicOnly,
Unrestricted,
}
impl AddressPolicy {
pub fn from_allow_private(allow_private: bool) -> Self {
if allow_private {
Self::Unrestricted
} else {
Self::PublicOnly
}
}
pub fn allows(self, ip: IpAddr) -> bool {
self == Self::Unrestricted || is_public(ip)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetError {
#[error("address {0} is not publicly routable")]
Blocked(IpAddr),
#[error("host {0} did not resolve")]
Unresolvable(String),
#[error("too many redirects")]
TooManyRedirects,
}
pub fn is_public(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_public_v4(v4),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => is_public_v4(v4),
None => is_public_v6(v6),
},
}
}
pub const BLOCKED_V4: &[(&str, u8)] = &[
("0.0.0.0", 8), ("10.0.0.0", 8), ("100.64.0.0", 10), ("127.0.0.0", 8), ("169.254.0.0", 16), ("172.16.0.0", 12), ("192.0.2.0", 24), ("192.168.0.0", 16), ("198.18.0.0", 15), ("198.51.100.0", 24), ("203.0.113.0", 24), ("224.0.0.0", 4), ("240.0.0.0", 4), ];
pub const BLOCKED_V6: &[(&str, u8)] = &[
("::", 96), ("::ffff:0:0", 96), ("2001:db8::", 32), ("fc00::", 7), ("fe80::", 10), ("ff00::", 8), ];
pub fn sandbox_net_rules() -> String {
let mut rules = vec!["ipv4:allow=*:*".to_string(), "ipv6:allow=*:*".to_string()];
rules.extend(
BLOCKED_V4
.iter()
.map(|(net, bits)| format!("ipv4:deny={net}/{bits}:*")),
);
rules.extend(
BLOCKED_V6
.iter()
.map(|(net, bits)| format!("ipv6:deny={net}/{bits}:*")),
);
rules.push("dns:allow=*:*".to_string());
rules.join(",")
}
fn is_public_v4(ip: Ipv4Addr) -> bool {
let [a, b, ..] = ip.octets();
!(ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_documentation()
|| ip.is_multicast()
|| a == 0
|| (a == 100 && (64..128).contains(&b))
|| (a == 198 && (18..20).contains(&b))
|| a >= 240)
}
fn is_public_v6(ip: Ipv6Addr) -> bool {
let seg = ip.segments();
!(ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_multicast()
|| (seg[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xffc0) == 0xfe80
|| (seg[0] == 0x2001 && seg[1] == 0x0db8)
|| ip.to_ipv4().is_some())
}
pub fn check_url(url: &str, policy: AddressPolicy) -> Result<(), NetError> {
let Ok(parsed) = reqwest::Url::parse(url) else {
return Ok(());
};
match literal_ip(&parsed) {
Some(ip) if !policy.allows(ip) => Err(NetError::Blocked(ip)),
_ => Ok(()),
}
}
fn literal_ip(url: &reqwest::Url) -> Option<IpAddr> {
let host = url.host_str()?;
let bare = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
bare.parse().ok()
}
pub fn was_blocked(err: &reqwest::Error) -> bool {
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = source {
if e.downcast_ref::<NetError>().is_some() {
return true;
}
source = e.source();
}
false
}
#[derive(Debug)]
struct GuardedResolver {
policy: AddressPolicy,
}
impl reqwest::dns::Resolve for GuardedResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let policy = self.policy;
let host = name.as_str().to_string();
Box::pin(async move {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.collect();
if addrs.is_empty() {
return Err(NetError::Unresolvable(host).into());
}
if let Some(bad) = addrs.iter().find(|a| !policy.allows(a.ip())) {
return Err(NetError::Blocked(bad.ip()).into());
}
Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
})
}
}
fn redirect_policy(policy: AddressPolicy) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
if let Some(ip) = literal_ip(attempt.url())
&& !policy.allows(ip)
{
return attempt.error(NetError::Blocked(ip));
}
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error(NetError::TooManyRedirects);
}
attempt.follow()
})
}
pub struct GuardedClient {
inner: reqwest::Client,
policy: AddressPolicy,
}
impl GuardedClient {
pub fn new(policy: AddressPolicy, timeout: Duration) -> Self {
Self {
inner: guarded_client(policy, timeout),
policy,
}
}
pub fn get(&self, url: &str) -> Result<reqwest::RequestBuilder, NetError> {
check_url(url, self.policy)?;
Ok(self.inner.get(url))
}
pub fn post(&self, url: &str) -> Result<reqwest::RequestBuilder, NetError> {
check_url(url, self.policy)?;
Ok(self.inner.post(url))
}
pub fn unchecked_inner(&self) -> &reqwest::Client {
&self.inner
}
}
fn guarded_client(policy: AddressPolicy, timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.dns_resolver(Arc::new(GuardedResolver { policy }))
.redirect(redirect_policy(policy))
.build()
.unwrap_or_else(|_| {
reqwest::Client::builder()
.dns_resolver(Arc::new(GuardedResolver { policy }))
.redirect(redirect_policy(policy))
.build()
.expect("a client with no TLS options must build")
})
}
#[cfg(test)]
pub(crate) mod stub {
use std::sync::Arc;
use super::RESPONSE;
pub(crate) fn counting_stub() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let hits = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&hits);
std::thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
counter.fetch_add(1, Ordering::SeqCst);
use std::io::{Read, Write};
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let _ = stream.write_all(RESPONSE);
}
});
(format!("http://{addr}/admin"), hits)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn blocked_ranges_match_the_predicate() {
fn v4_range(net: &str, bits: u8) -> (u32, u32) {
let base = u32::from(net.parse::<Ipv4Addr>().unwrap());
let size = 1u64 << (32 - bits);
(base, (base as u64 | (size - 1)) as u32)
}
fn v6_range(net: &str, bits: u8) -> (u128, u128) {
let base = u128::from(net.parse::<Ipv6Addr>().unwrap());
let size = 1u128 << (128 - bits);
(base, base | (size - 1))
}
let listed_v4 = |a: Ipv4Addr| {
BLOCKED_V4.iter().any(|(net, bits)| {
let (lo, hi) = v4_range(net, *bits);
(lo..=hi).contains(&u32::from(a))
})
};
let listed_v6 = |a: Ipv6Addr| {
BLOCKED_V6.iter().any(|(net, bits)| {
let (lo, hi) = v6_range(net, *bits);
(lo..=hi).contains(&u128::from(a))
})
};
for (net, bits) in BLOCKED_V4 {
let (lo, hi) = v4_range(net, *bits);
for probe in [lo, lo + 1, hi - 1, hi] {
let a = Ipv4Addr::from(probe);
assert!(!is_public(IpAddr::V4(a)), "{a} is listed but public");
}
for outside in [lo.checked_sub(1), hi.checked_add(1)] {
let Some(a) = outside.map(Ipv4Addr::from) else {
continue;
};
assert_eq!(
listed_v4(a),
!is_public(IpAddr::V4(a)),
"{a}, just outside {net}/{bits}, is judged differently by the two"
);
}
}
for (net, bits) in BLOCKED_V6 {
let (lo, hi) = v6_range(net, *bits);
for probe in [lo, lo + 1, hi - 1, hi] {
let a = Ipv6Addr::from(probe);
assert!(!is_public(IpAddr::V6(a)), "{a} is listed but public");
}
for outside in [lo.checked_sub(1), hi.checked_add(1)] {
let Some(a) = outside.map(Ipv6Addr::from) else {
continue;
};
assert_eq!(
listed_v6(a),
!is_public(IpAddr::V6(a)),
"{a}, just outside {net}/{bits}, is judged differently by the two"
);
}
}
for a in [
"1.1.1.1",
"93.184.216.34",
"2606:2800:220:1::",
"2a00:1450::1",
] {
let a = ip(a);
assert!(is_public(a), "{a}");
match a {
IpAddr::V4(v4) => assert!(!listed_v4(v4)),
IpAddr::V6(v6) => assert!(!listed_v6(v6)),
}
}
}
#[test]
fn sandbox_rules_allow_then_deny_every_range() {
let rules = sandbox_net_rules();
for required in ["ipv4:allow=*:*", "ipv6:allow=*:*", "dns:allow=*:*"] {
assert!(rules.contains(required), "{required} missing: {rules}");
}
assert!(rules.contains("ipv4:deny=127.0.0.0/8:*"), "{rules}");
assert!(rules.contains("ipv6:deny=fe80::/10:*"), "{rules}");
assert_eq!(
rules.split(',').filter(|r| r.contains("deny")).count(),
BLOCKED_V4.len() + BLOCKED_V6.len()
);
}
#[test]
fn the_classifier_refuses_local_ranges_and_allows_the_public_internet() {
for addr in [
"127.0.0.1",
"127.1.2.3",
"0.0.0.0",
"0.1.2.3",
"10.0.0.1",
"172.16.5.4",
"172.31.255.255",
"192.168.1.20",
"169.254.169.254", "255.255.255.255",
"224.0.0.1",
"100.64.0.1",
"198.18.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12:3456::1",
"fe80::1",
"ff02::1",
"2001:db8::1",
] {
assert!(!is_public(ip(addr)), "{addr} must be refused");
}
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1", "192.169.0.1", "100.128.0.1", "198.20.0.1", "2606:4700::1111",
"2001:db9::1", ] {
assert!(is_public(ip(addr)), "{addr} must be allowed");
}
}
#[test]
fn ipv4_addresses_wearing_an_ipv6_spelling_are_still_refused() {
for addr in [
"::ffff:127.0.0.1",
"::ffff:10.0.0.1",
"::ffff:169.254.169.254",
"::127.0.0.1", ] {
assert!(!is_public(ip(addr)), "{addr} must be refused");
}
assert!(is_public(ip("::ffff:8.8.8.8")));
}
#[test]
fn the_permissive_policy_allows_what_the_default_refuses() {
assert!(!AddressPolicy::PublicOnly.allows(ip("127.0.0.1")));
assert!(AddressPolicy::Unrestricted.allows(ip("127.0.0.1")));
assert!(AddressPolicy::from_allow_private(false).allows(ip("8.8.8.8")));
assert_eq!(
AddressPolicy::from_allow_private(true),
AddressPolicy::Unrestricted
);
}
use super::stub::counting_stub;
#[tokio::test]
async fn a_loopback_service_is_unreachable_by_default_and_reachable_when_allowed() {
use std::sync::atomic::Ordering;
let (url, hits) = counting_stub();
let guarded = GuardedClient::new(AddressPolicy::PublicOnly, Duration::from_secs(5));
assert_eq!(
guarded.get(&url).err(),
Some(NetError::Blocked("127.0.0.1".parse().unwrap()))
);
assert_eq!(
hits.load(Ordering::SeqCst),
0,
"the guard must refuse before the connection, not after"
);
let permissive = GuardedClient::new(AddressPolicy::Unrestricted, Duration::from_secs(5));
let body = permissive
.get(&url)
.unwrap()
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "hi");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn an_ordinary_transport_failure_is_not_mistaken_for_a_refusal() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let permissive = GuardedClient::new(AddressPolicy::Unrestricted, Duration::from_secs(5));
let err = permissive
.get(&format!("http://{addr}/"))
.unwrap()
.send()
.await
.unwrap_err();
assert!(
!was_blocked(&err),
"a dead port is not a policy refusal: {err}"
);
}
#[tokio::test]
async fn a_hostname_resolving_to_loopback_is_refused_by_the_resolver() {
let (url, hits) = counting_stub();
let port = url
.rsplit(':')
.next()
.unwrap()
.split('/')
.next()
.unwrap()
.to_string();
let guarded = GuardedClient::new(AddressPolicy::PublicOnly, Duration::from_secs(5));
let err = guarded
.get(&format!("http://localhost:{port}/admin"))
.expect("a name is not a literal, so this check passes and the resolver decides")
.send()
.await
.unwrap_err();
assert!(
was_blocked(&err),
"resolved to loopback, must be refused: {err}"
);
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"a name that resolves locally must not be connected to either"
);
}
#[test]
fn a_literal_address_in_the_url_is_judged_without_a_request() {
assert_eq!(
check_url("http://127.0.0.1:8000/admin", AddressPolicy::PublicOnly),
Err(NetError::Blocked(ip("127.0.0.1")))
);
assert_eq!(
check_url("http://[::1]:8000/", AddressPolicy::PublicOnly),
Err(NetError::Blocked(ip("::1")))
);
assert_eq!(
check_url("http://[::ffff:127.0.0.1]/", AddressPolicy::PublicOnly),
Err(NetError::Blocked(ip("::ffff:127.0.0.1")))
);
assert_eq!(
check_url(
"http://169.254.169.254/latest/meta-data/",
AddressPolicy::PublicOnly
),
Err(NetError::Blocked(ip("169.254.169.254")))
);
assert_eq!(
check_url("https://example.com/a", AddressPolicy::PublicOnly),
Ok(())
);
assert_eq!(
check_url("http://127.0.0.1/", AddressPolicy::Unrestricted),
Ok(())
);
}
}