use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use futures::future::BoxFuture;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use tokio::sync::{Semaphore, SemaphorePermit};
const ALLOWED_SCHEMES: &[&str] = &["http", "https"];
const HOST_LIMITER_STRIPES: usize = 64;
pub fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_public_ipv4(v4),
IpAddr::V6(v6) => is_public_ipv6(v6),
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
let special = 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; !special
}
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
if let Some(v4) = embedded_ipv4(ip) {
return is_public_ipv4(v4);
}
let s = ip.segments();
let global_unicast = (s[0] & 0xe000) == 0x2000;
global_unicast
&& !(s[0] == 0x2001 && s[1] < 0x0200) && !(s[0] == 0x2001 && s[1] == 0x0db8) && !(s[0] == 0x3fff && s[1] < 0x1000) }
fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
let s = ip.segments();
let low =
|hi: u16, lo: u16| Ipv4Addr::new((hi >> 8) as u8, hi as u8, (lo >> 8) as u8, lo as u8);
match s {
[0, 0, 0, 0, 0, 0xffff, hi, lo] | [0, 0, 0, 0, 0, 0, hi, lo] => Some(low(hi, lo)),
[0x64, 0xff9b, 0, 0, 0, 0, hi, lo] => Some(low(hi, lo)),
[0x2002, hi, lo, ..] => Some(low(hi, lo)),
_ => None,
}
}
#[derive(Debug)]
pub struct BlockedUrlError(pub(crate) String);
impl std::fmt::Display for BlockedUrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "URL resolver request blocked: {}", self.0)
}
}
impl std::error::Error for BlockedUrlError {}
#[derive(Debug, Clone, Default)]
pub(crate) struct AddressPolicy {
pub(crate) allow_private_networks: bool,
pub(crate) exempt: Vec<IpAddr>,
}
impl AddressPolicy {
pub(crate) fn permits(&self, ip: IpAddr) -> bool {
self.allow_private_networks || is_public_ip(ip) || self.exempt.contains(&ip)
}
pub(crate) fn check_url(&self, url: &reqwest::Url) -> Result<(), BlockedUrlError> {
if !ALLOWED_SCHEMES.contains(&url.scheme()) {
return Err(BlockedUrlError(format!(
"scheme '{}' is not allowed",
url.scheme()
)));
}
let host = url
.host_str()
.ok_or_else(|| BlockedUrlError("URL has no host".to_string()))?;
let literal = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = literal.parse::<IpAddr>() {
if !self.permits(ip) {
return Err(BlockedUrlError(format!("{ip} is not a public address")));
}
}
Ok(())
}
}
pub(crate) type LookupFn =
Arc<dyn Fn(String) -> BoxFuture<'static, std::io::Result<Vec<IpAddr>>> + Send + Sync>;
pub(crate) fn system_lookup() -> LookupFn {
Arc::new(|host: String| {
Box::pin(async move {
let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
Ok(addrs.map(|addr| addr.ip()).collect())
})
})
}
pub(crate) struct GuardedResolver {
lookup: LookupFn,
policy: Arc<AddressPolicy>,
}
impl GuardedResolver {
pub(crate) fn new(lookup: LookupFn, policy: Arc<AddressPolicy>) -> Self {
Self { lookup, policy }
}
}
impl Resolve for GuardedResolver {
fn resolve(&self, name: Name) -> Resolving {
let host = name.as_str().to_string();
let lookup = self.lookup.clone();
let policy = self.policy.clone();
Box::pin(async move {
let ips = lookup(host.clone()).await?;
if ips.is_empty() {
return Err(format!("no addresses found for {host}").into());
}
if let Some(ip) = ips.iter().find(|ip| !policy.permits(**ip)) {
return Err(Box::new(BlockedUrlError(format!(
"{host} resolves to {ip}, which is not a public address"
)))
as Box<dyn std::error::Error + Send + Sync>);
}
let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
Ok(addrs)
})
}
}
pub(crate) struct HostLimiter {
total: Semaphore,
per_host: Vec<Semaphore>,
}
impl HostLimiter {
pub(crate) fn new(max_total: usize, max_per_host: usize) -> Self {
Self {
total: Semaphore::new(max_total.max(1)),
per_host: (0..HOST_LIMITER_STRIPES)
.map(|_| Semaphore::new(max_per_host.max(1)))
.collect(),
}
}
pub(crate) async fn acquire(&self, host: &str) -> (SemaphorePermit<'_>, SemaphorePermit<'_>) {
let mut hasher = DefaultHasher::new();
host.to_ascii_lowercase().hash(&mut hasher);
let stripe = &self.per_host[(hasher.finish() as usize) % self.per_host.len()];
let host_permit = stripe.acquire().await.expect("semaphore is never closed");
let total_permit = self
.total
.acquire()
.await
.expect("semaphore is never closed");
(host_permit, total_permit)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn internal_ipv4_addresses_are_not_public() {
for addr in [
"0.0.0.0",
"10.1.2.3",
"100.64.0.1",
"127.0.0.1",
"127.255.255.254",
"169.254.169.254",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"192.0.0.1",
"198.18.0.1",
"224.0.0.1",
"255.255.255.255",
] {
assert!(!is_public_ip(ip(addr)), "{addr} should be blocked");
}
}
#[test]
fn internal_ipv6_addresses_are_not_public() {
for addr in [
"::",
"::1",
"fc00::1",
"fd12:3456::1",
"fe80::1",
"fec0::1",
"ff02::1",
"2001:db8::1",
"2001::1",
"64:ff9b:1::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"::ffff:10.0.0.1",
"::127.0.0.1",
"64:ff9b::a9fe:a9fe",
"2002:a9fe:a9fe::1",
"2002:c0a8:0101::1",
] {
assert!(!is_public_ip(ip(addr)), "{addr} should be blocked");
}
}
#[test]
fn public_addresses_are_allowed() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"100.128.0.1",
"2606:4700:4700::1111",
"2001:4860:4860::8888",
"::ffff:8.8.8.8",
"64:ff9b::808:808",
] {
assert!(is_public_ip(ip(addr)), "{addr} should be allowed");
}
}
#[test]
fn check_url_rejects_other_schemes_and_internal_literals() {
let policy = AddressPolicy::default();
for url in [
"file:///etc/passwd",
"ftp://example.com/x",
"gopher://example.com/",
"http://127.0.0.1/",
"http://localhost.:80@127.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1:8080/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://[fd00::1]/",
"http://2130706433/",
"http://0x7f000001/",
] {
let parsed = reqwest::Url::parse(url).unwrap();
assert!(
policy.check_url(&parsed).is_err(),
"{url} should be blocked"
);
}
for url in ["https://example.com/a.json", "http://8.8.8.8/"] {
let parsed = reqwest::Url::parse(url).unwrap();
assert!(policy.check_url(&parsed).is_ok(), "{url} should be allowed");
}
}
#[test]
fn allow_private_networks_disables_the_address_check() {
let policy = AddressPolicy {
allow_private_networks: true,
exempt: Vec::new(),
};
let parsed = reqwest::Url::parse("http://127.0.0.1:8080/").unwrap();
assert!(policy.check_url(&parsed).is_ok());
let parsed = reqwest::Url::parse("file:///etc/passwd").unwrap();
assert!(policy.check_url(&parsed).is_err());
}
#[tokio::test]
async fn resolver_refuses_names_with_any_internal_address() {
let lookup: LookupFn = Arc::new(|host: String| {
Box::pin(async move {
Ok(match host.as_str() {
"public.test" => vec!["93.184.216.34".parse().unwrap()],
"mixed.test" => vec![
"93.184.216.34".parse().unwrap(),
"10.0.0.1".parse().unwrap(),
],
_ => vec!["169.254.169.254".parse().unwrap()],
})
})
});
let resolver = GuardedResolver::new(lookup, Arc::new(AddressPolicy::default()));
let addrs: Vec<SocketAddr> = resolver
.resolve("public.test".parse().unwrap())
.await
.unwrap()
.collect();
assert_eq!(addrs, vec!["93.184.216.34:0".parse().unwrap()]);
assert!(resolver
.resolve("mixed.test".parse().unwrap())
.await
.is_err());
assert!(resolver
.resolve("metadata.test".parse().unwrap())
.await
.is_err());
}
#[tokio::test]
async fn host_limiter_caps_concurrency_per_host() {
let limiter = HostLimiter::new(8, 1);
let first = limiter.acquire("a.example").await;
let blocked = tokio::time::timeout(
std::time::Duration::from_millis(50),
limiter.acquire("A.example"),
)
.await;
assert!(blocked.is_err());
drop(first);
let _second = limiter.acquire("a.example").await;
}
}