use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::sync::Arc;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::redirect;
use url::{Host, Url};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const MAX_REDIRECTS: usize = 10;
pub fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_blocked_ipv4(ip),
IpAddr::V6(ip) => {
if let Some(embedded) = ip.to_ipv4() {
if is_blocked_ipv4(embedded) {
return true;
}
}
is_blocked_ipv6(ip)
}
}
}
fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
let octets = ip.octets();
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_unspecified() || octets[0] == 0
|| (octets[0] == 100 && (octets[1] & 0b1100_0000) == 0b0100_0000)
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|| (octets[0] == 198 && (octets[1] & 0b1111_1110) == 18)
|| octets[0] >= 240
}
fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
let segments = ip.segments();
ip.is_loopback() || ip.is_unspecified() || (segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
}
#[derive(Debug, Clone, Default)]
pub struct SsrfGuardResolver;
impl Resolve for SsrfGuardResolver {
fn resolve(&self, name: Name) -> Resolving {
Box::pin(async move {
let host = name.as_str().to_owned();
let resolved = tokio::task::spawn_blocking(move || {
(host.as_str(), 0_u16)
.to_socket_addrs()
.map(|addresses| addresses.collect::<Vec<_>>())
})
.await
.map_err(|error| Box::new(error) as BoxError)?
.map_err(|error| Box::new(error) as BoxError)?;
let allowed: Vec<SocketAddr> = resolved
.into_iter()
.filter(|address| !is_blocked_ip(address.ip()))
.collect();
if allowed.is_empty() {
return Err(BoxError::from(
"refusing to connect: host resolves only to private or internal IP addresses",
));
}
Ok(Box::new(allowed.into_iter()) as Addrs)
})
}
}
pub fn url_host_is_blocked_ip(url: &str) -> bool {
Url::parse(url)
.ok()
.is_some_and(|url| url_target_is_blocked_ip(&url))
}
fn url_target_is_blocked_ip(url: &Url) -> bool {
match url.host() {
Some(Host::Ipv4(ip)) => is_blocked_ip(IpAddr::V4(ip)),
Some(Host::Ipv6(ip)) => is_blocked_ip(IpAddr::V6(ip)),
_ => false,
}
}
pub fn ssrf_guarded_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.dns_resolver(Arc::new(SsrfGuardResolver))
.redirect(redirect::Policy::custom(|attempt| {
if url_target_is_blocked_ip(attempt.url()) {
attempt.error(BoxError::from(
"refusing to follow redirect to a private or internal IP address",
))
} else if attempt.previous().len() >= MAX_REDIRECTS {
attempt.error(BoxError::from("too many redirects"))
} else {
attempt.follow()
}
}))
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn blocks_private_and_internal_ipv4_ranges() -> Result<(), std::net::AddrParseError> {
for address in [
"127.0.0.1",
"127.10.20.30",
"10.0.0.1",
"172.16.5.4",
"192.168.1.1",
"169.254.169.254", "100.64.0.1", "192.0.0.1", "198.18.0.1", "240.0.0.1", "0.0.0.0",
"255.255.255.255",
] {
let ip = IpAddr::V4(address.parse::<Ipv4Addr>()?);
assert!(is_blocked_ip(ip), "expected {address} blocked");
}
Ok(())
}
#[test]
fn allows_public_ipv4_addresses() -> Result<(), std::net::AddrParseError> {
for address in ["1.1.1.1", "8.8.8.8", "93.184.216.34", "203.0.114.1"] {
let ip = IpAddr::V4(address.parse::<Ipv4Addr>()?);
assert!(!is_blocked_ip(ip), "expected {address} allowed");
}
Ok(())
}
#[test]
fn blocks_private_and_internal_ipv6_ranges() -> Result<(), std::net::AddrParseError> {
for address in [
"::1",
"::",
"fc00::1",
"fd12:3456::1",
"fe80::1",
"2001:db8::1",
"::ffff:127.0.0.1", "::ffff:10.0.0.1", ] {
let ip = IpAddr::V6(address.parse::<Ipv6Addr>()?);
assert!(is_blocked_ip(ip), "expected {address} blocked");
}
Ok(())
}
#[test]
fn allows_public_ipv6_addresses() -> Result<(), std::net::AddrParseError> {
for address in ["2606:4700:4700::1111", "2001:4860:4860::8888"] {
let ip = IpAddr::V6(address.parse::<Ipv6Addr>()?);
assert!(!is_blocked_ip(ip), "expected {address} allowed");
}
Ok(())
}
#[test]
fn url_host_check_blocks_literal_private_ips_only() {
for url in [
"http://127.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"https://10.0.0.5:8443/token",
"http://[::1]/",
"http://[fd00::1]/",
"http://0.0.0.0/",
] {
assert!(url_host_is_blocked_ip(url), "expected {url} blocked");
}
for url in [
"https://idp.example.com/.well-known/openid-configuration",
"http://1.1.1.1/",
"https://[2606:4700:4700::1111]/",
"not a url",
] {
assert!(!url_host_is_blocked_ip(url), "expected {url} allowed");
}
}
#[tokio::test]
async fn resolver_blocks_hostnames_that_resolve_to_loopback(
) -> Result<(), Box<dyn std::error::Error>> {
let resolved = SsrfGuardResolver
.resolve(Name::from_str("localhost")?)
.await;
assert!(
resolved.is_err(),
"expected localhost resolution to be refused"
);
Ok(())
}
#[tokio::test]
async fn guarded_client_refuses_redirect_to_literal_private_ip(
) -> Result<(), Box<dyn std::error::Error>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buffer = [0_u8; 256];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buffer).await;
let response = "HTTP/1.1 302 Found\r\nlocation: http://169.254.169.254/\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
let _ =
tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes()).await;
});
}
});
let client = ssrf_guarded_client_builder().build()?;
let error = match client.get(format!("http://{address}/")).send().await {
Ok(_) => return Err("expected redirect to private IP to be refused".into()),
Err(error) => error,
};
assert!(
error.is_redirect() || error.to_string().contains("redirect"),
"unexpected error: {error}"
);
Ok(())
}
}