use std::net::{IpAddr, ToSocketAddrs};
use std::time::Duration;
use reqwest::Url;
const BLOCKED_HOSTNAMES: &[&str] = &["metadata.google.internal", "metadata.goog", "metadata"];
fn is_cgnat(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
octets[0] == 100 && (octets[1] & 0xC0) == 64
}
IpAddr::V6(_) => false,
}
}
fn is_link_local(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
let segments = v6.segments();
(segments[0] & 0xFFC0) == 0xFE80
}
}
}
pub fn is_blocked_ip(ip: &IpAddr) -> bool {
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
return true;
}
if is_link_local(ip) {
return true;
}
if is_cgnat(ip) {
return true;
}
match ip {
IpAddr::V4(v4) => {
if v4.is_private() {
return true;
}
if v4.is_broadcast() {
return true;
}
let octets = v4.octets();
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
return true;
}
if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
return true;
}
false
}
IpAddr::V6(v6) => {
let segs = v6.segments();
if (segs[0] & 0xFE00) == 0xFC00 {
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_blocked_ip(&IpAddr::V4(v4));
}
false
}
}
}
fn resolve_hostname(hostname: &str, timeout: Duration) -> Option<Vec<IpAddr>> {
let host = hostname.to_string();
let handle = std::thread::spawn(move || {
(host.as_str(), 0u16)
.to_socket_addrs()
.ok()
.map(|iter| iter.map(|sa| sa.ip()).collect::<Vec<_>>())
});
let start = std::time::Instant::now();
loop {
if handle.is_finished() {
return handle.join().ok().flatten();
}
if start.elapsed() >= timeout {
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
}
pub fn check_url_private_ip(url: &Url) -> Result<(), String> {
let Some(host) = url.host_str() else {
return Err("URL has no host".to_string());
};
let host_lower = host.to_ascii_lowercase();
let hostname = host_lower.trim_end_matches('.');
if BLOCKED_HOSTNAMES.contains(&hostname) {
return Err(format!(
"URL host '{host}' points at an internal metadata endpoint"
));
}
let ip_candidate = hostname.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
return Err(format!(
"URL host '{host}' resolves to a blocked address ({ip})"
));
}
return Ok(());
}
let Some(ips) = resolve_hostname(hostname, Duration::from_secs(3)) else {
return Err(format!(
"URL host '{host}' failed DNS resolution (SSRF check fails closed)"
));
};
if ips.is_empty() {
return Err(format!("URL host '{host}' has no resolved IPs"));
}
for ip in &ips {
if is_blocked_ip(ip) {
return Err(format!(
"URL host '{host}' resolves to a blocked private/internal address ({ip})"
));
}
}
Ok(())
}
pub fn check_raw_url_private_ip(raw_url: &str) -> Result<(), String> {
let parsed = Url::parse(raw_url).map_err(|e| format!("invalid URL: {e}"))?;
check_url_private_ip(&parsed)
}
pub fn ssrf_redirect_policy(max_redirects: usize) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= max_redirects {
return attempt.error(format!("too many redirects (>{max_redirects})"));
}
if let Err(e) = check_url_private_ip(attempt.url()) {
return attempt.error(format!("SSRF guard blocked redirect: {e}"));
}
attempt.follow()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_loopback_literal() {
let err = check_raw_url_private_ip("http://127.0.0.1/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn blocks_cloud_metadata_literal() {
let err = check_raw_url_private_ip("http://169.254.169.254/latest/meta-data/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn blocks_cloud_metadata_hostname() {
let err = check_raw_url_private_ip("http://metadata.google.internal/").unwrap_err();
assert!(err.contains("metadata"));
}
#[test]
fn blocks_private_range_literal() {
let err = check_raw_url_private_ip("http://10.0.0.1/").unwrap_err();
assert!(err.contains("blocked"));
let err = check_raw_url_private_ip("http://192.168.1.1/").unwrap_err();
assert!(err.contains("blocked"));
let err = check_raw_url_private_ip("http://172.16.0.1/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn blocks_cgnat_literal() {
let err = check_raw_url_private_ip("http://100.64.1.2/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn blocks_ipv6_loopback() {
let err = check_raw_url_private_ip("http://[::1]/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn blocks_unique_local_ipv6() {
let err = check_raw_url_private_ip("http://[fc00::1]/").unwrap_err();
assert!(err.contains("blocked"));
}
#[test]
fn allows_public_ipv4_literal() {
assert!(check_raw_url_private_ip("https://1.1.1.1/").is_ok());
}
#[test]
fn rejects_missing_host() {
let err = check_raw_url_private_ip("file:///etc/passwd").unwrap_err();
assert!(err.contains("no host") || err.contains("invalid URL"));
}
}