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();
if let Some(v4) = embedded_ipv4(ip) {
if is_blocked_ipv4(v4) {
return true;
}
}
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (seg[0] & 0xffc0) == 0xfe80 || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
let seg = ip.segments();
let from = |hi: u16, lo: u16| Some(Ipv4Addr::from(((hi as u32) << 16) | lo as u32));
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0 && seg[3] == 0 && seg[4] == 0 {
return from(seg[6], seg[7]);
}
if seg[0] == 0x2002 {
return from(seg[1], seg[2]);
}
None
}
const BLOCKED_PORTS: [u16; 21] = [
22, 23, 25, 53, 69, 110, 119, 135, 137, 139, 143, 445, 465, 587, 993, 995, 1433, 3306, 5432, 6379, 11211, ];
#[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 {}
fn check_scheme(url: &Url) -> Result<(), BlockedUrl> {
match url.scheme() {
"http" | "https" => Ok(()),
other => Err(BlockedUrl(format!("scheme `{other}` not allowed"))),
}
}
fn check_port(url: &Url) -> Result<(), BlockedUrl> {
match url.port() {
Some(port) if BLOCKED_PORTS.contains(&port) => Err(BlockedUrl(format!(
"port {port} is not an HTTP service and is not fetchable"
))),
_ => Ok(()),
}
}
pub async fn validate_url(url: &Url) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
check_scheme(url)?;
check_port(url)?;
if allow_private() {
return Ok(Vec::new());
}
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")); }
#[test]
fn blocks_ipv4_embedded_in_transition_addresses() {
assert!(blocked("64:ff9b::169.254.169.254")); assert!(blocked("64:ff9b::a00:1")); assert!(blocked("2002:a9fe:a9fe::1")); assert!(blocked("2002:7f00:1::1")); assert!(!blocked("64:ff9b::8.8.8.8"));
assert!(!blocked("2002:0808:0808::1")); }
#[tokio::test]
async fn rejects_non_http_ports() {
for target in [
"http://example.com:22/",
"http://example.com:6379/",
"https://example.com:3306/",
"http://example.com:25/",
] {
let url = Url::parse(target).unwrap();
assert!(
validate_url(&url).await.is_err(),
"{target} should be blocked"
);
}
}
#[test]
fn allows_ordinary_http_ports() {
for target in [
"http://example.com/",
"https://example.com/",
"http://example.com:8080/",
"http://example.com:3000/",
"https://example.com:8443/",
] {
assert!(check_port(&Url::parse(target).unwrap()).is_ok(), "{target}");
}
}
#[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());
}
#[test]
fn scheme_check_is_independent_of_the_env_opt_out() {
for bad in ["file:///etc/passwd", "ftp://example.com/x", "gopher://x/"] {
assert!(check_scheme(&Url::parse(bad).unwrap()).is_err(), "{bad}");
}
assert!(check_scheme(&Url::parse("https://example.com").unwrap()).is_ok());
}
#[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"
);
}
}
}