use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Once;
use url::{Host, Url};
const ALLOW_PRIVATE_ENV: &str = "WEBFETCH_ALLOW_PRIVATE";
static ALLOW_PRIVATE_WARNING: Once = Once::new();
pub fn allow_private() -> bool {
let enabled = matches!(
std::env::var(ALLOW_PRIVATE_ENV).ok().as_deref(),
Some("1") | Some("true") | Some("TRUE")
);
if enabled {
ALLOW_PRIVATE_WARNING.call_once(|| {
eprintln!(
"warning: {ALLOW_PRIVATE_ENV} is set — SSRF guard disabled; \
private, loopback, and metadata IPs are reachable"
);
});
}
enabled
}
pub fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_blocked_ipv4(v4),
IpAddr::V6(v6) => is_blocked_ipv6(v6),
}
}
fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
let o = ip.octets();
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_unspecified() || ip.is_multicast() || ip.is_documentation() || o[0] == 0 || (o[0] == 100 && (o[1] & 0xc0) == 64) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || (o[0] == 198 && (o[1] & 0xfe) == 18) || o[0] >= 240 }
fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4_mapped() {
return is_blocked_ipv4(v4);
}
if let Some(v4) = ip.to_ipv4() {
return is_blocked_ipv4(v4);
}
let seg = ip.segments();
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (seg[0] & 0xffc0) == 0xfe80 || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
#[derive(Debug)]
pub struct BlockedUrl(pub String);
impl std::fmt::Display for BlockedUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "blocked URL: {}", self.0)
}
}
impl std::error::Error for BlockedUrl {}
pub async fn validate_url(url: &Url) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
if allow_private() {
return Ok(Vec::new());
}
match url.scheme() {
"http" | "https" => {}
other => return Err(BlockedUrl(format!("scheme `{other}` not allowed"))),
}
let host = url
.host()
.ok_or_else(|| BlockedUrl(format!("no host in {url}")))?;
match host {
Host::Ipv4(ip) => {
if is_blocked_ip(IpAddr::V4(ip)) {
return Err(BlockedUrl(format!("host IP {ip} is not public")));
}
Ok(Vec::new())
}
Host::Ipv6(ip) => {
if is_blocked_ip(IpAddr::V6(ip)) {
return Err(BlockedUrl(format!("host IP {ip} is not public")));
}
Ok(Vec::new())
}
Host::Domain(domain) => validate_domain(url, domain).await,
}
}
async fn validate_domain(url: &Url, domain: &str) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
let lower = domain.to_ascii_lowercase();
if lower == "localhost" || lower.ends_with(".localhost") {
return Err(BlockedUrl(format!("host `{domain}` is local")));
}
let port = url
.port_or_known_default()
.ok_or_else(|| BlockedUrl(format!("no port for {url}")))?;
let addrs: Vec<_> = tokio::net::lookup_host((domain, port))
.await
.map_err(|e| BlockedUrl(format!("cannot resolve `{domain}`: {e}")))?
.collect();
if addrs.is_empty() {
return Err(BlockedUrl(format!("`{domain}` resolved to no addresses")));
}
for addr in &addrs {
if is_blocked_ip(addr.ip()) {
return Err(BlockedUrl(format!(
"`{domain}` resolves to non-public IP {}",
addr.ip()
)));
}
}
Ok(addrs)
}
#[cfg(test)]
mod tests {
use super::*;
fn blocked(s: &str) -> bool {
is_blocked_ip(s.parse::<IpAddr>().unwrap())
}
#[test]
fn blocks_loopback_and_private_and_metadata() {
assert!(blocked("127.0.0.1"));
assert!(blocked("10.0.0.1"));
assert!(blocked("172.16.5.4"));
assert!(blocked("192.168.1.1"));
assert!(blocked("169.254.169.254")); assert!(blocked("100.64.0.1")); assert!(blocked("0.0.0.0"));
assert!(blocked("255.255.255.255"));
assert!(blocked("224.0.0.1")); assert!(blocked("240.0.0.1")); }
#[test]
fn blocks_ipv6_local_and_mapped() {
assert!(blocked("::1")); assert!(blocked("::")); assert!(blocked("fe80::1")); assert!(blocked("fc00::1")); assert!(blocked("::ffff:127.0.0.1")); assert!(blocked("::ffff:169.254.169.254")); }
#[test]
fn allows_public() {
assert!(!blocked("1.1.1.1"));
assert!(!blocked("8.8.8.8"));
assert!(!blocked("93.184.216.34")); assert!(!blocked("2606:4700:4700::1111")); }
#[tokio::test]
async fn rejects_non_http_scheme() {
let url = Url::parse("file:///etc/passwd").unwrap();
assert!(validate_url(&url).await.is_err());
let url = Url::parse("ftp://example.com/x").unwrap();
assert!(validate_url(&url).await.is_err());
}
#[tokio::test]
async fn rejects_literal_metadata_ip_url() {
let url = Url::parse("http://169.254.169.254/latest/meta-data/").unwrap();
assert!(validate_url(&url).await.is_err());
}
#[tokio::test]
async fn rejects_localhost_name() {
let url = Url::parse("http://localhost:8080/admin").unwrap();
assert!(validate_url(&url).await.is_err());
}
#[tokio::test]
async fn rejects_redirect_target_to_private_ip() {
for target in [
"http://127.0.0.1/internal",
"http://10.0.0.1/admin",
"http://192.168.1.1/",
"http://169.254.169.254/latest/meta-data/",
] {
let url = Url::parse(target).unwrap();
assert!(
validate_url(&url).await.is_err(),
"redirect target {target} should be blocked"
);
}
}
}