use crate::config::SsrfConfig;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
pub fn validate_url(raw_url: &str, config: &SsrfConfig) -> Result<(), String> {
let parsed = Url::parse(raw_url).map_err(|e| format!("Invalid URL: {e}"))?;
let scheme = parsed.scheme().to_lowercase();
if !config.allowed_schemes.contains(&scheme) {
return Err(format!(
"URL scheme '{}' is not allowed. Allowed: {:?}",
scheme, config.allowed_schemes
));
}
let host = parsed
.host_str()
.ok_or_else(|| "URL has no host".to_string())?;
if config.blocklist.contains(host) {
return Err(format!("Host '{}' is in the blocklist", host));
}
if config.allowlist.contains(host) {
return Ok(());
}
if let Some(port) = parsed.port() {
if config.blocked_ports.contains(&port) {
return Err(format!(
"Port {} is blocked (common internal service port)",
port
));
}
}
if let Ok(ip) = host.parse::<IpAddr>() {
validate_ip(&ip, config)?;
} else {
validate_hostname(host, config)?;
}
Ok(())
}
pub fn validate_ip_str(ip_str: &str, config: &SsrfConfig) -> Result<(), String> {
if config.allowlist.contains(ip_str) {
return Ok(());
}
let ip: IpAddr = ip_str
.parse()
.map_err(|_| format!("Invalid IP address: {}", ip_str))?;
validate_ip(&ip, config)
}
fn validate_ip(ip: &IpAddr, config: &SsrfConfig) -> Result<(), String> {
match ip {
IpAddr::V4(v4) => validate_ipv4(v4, config),
IpAddr::V6(v6) => validate_ipv6(v6, config),
}
}
fn validate_ipv4(ip: &Ipv4Addr, config: &SsrfConfig) -> Result<(), String> {
let octets = ip.octets();
if config.block_loopback && octets[0] == 127 {
return Err(format!("Loopback address {} is blocked", ip));
}
if config.block_private_ips {
if octets[0] == 10 {
return Err(format!("Private IP {} (10.0.0.0/8) is blocked", ip));
}
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return Err(format!("Private IP {} (172.16.0.0/12) is blocked", ip));
}
if octets[0] == 192 && octets[1] == 168 {
return Err(format!("Private IP {} (192.168.0.0/16) is blocked", ip));
}
}
if config.block_link_local && octets[0] == 169 && octets[1] == 254 {
if config.block_metadata_endpoints && octets[2] == 169 && octets[3] == 254 {
return Err(format!(
"Cloud metadata endpoint {} is blocked (CVE-class SSRF target)",
ip
));
}
return Err(format!("Link-local address {} is blocked", ip));
}
if octets == [255, 255, 255, 255] {
return Err("Broadcast address is blocked".to_string());
}
if octets == [0, 0, 0, 0] {
return Err("Unspecified address 0.0.0.0 is blocked".to_string());
}
Ok(())
}
fn validate_ipv6(ip: &Ipv6Addr, config: &SsrfConfig) -> Result<(), String> {
if config.block_loopback && ip.is_loopback() {
return Err(format!("IPv6 loopback {} is blocked", ip));
}
if ip.segments() == [0; 8] {
return Err("IPv6 unspecified address :: is blocked".to_string());
}
if config.block_link_local {
let first_segment = ip.segments()[0];
if first_segment & 0xffc0 == 0xfe80 {
return Err(format!("IPv6 link-local address {} is blocked", ip));
}
}
if let Some(v4) = ip.to_ipv4_mapped() {
validate_ipv4(&v4, config)?;
}
Ok(())
}
fn validate_hostname(hostname: &str, config: &SsrfConfig) -> Result<(), String> {
let lower = hostname.to_lowercase();
if config.block_loopback
&& (lower == "localhost"
|| lower == "localhost.localdomain"
|| lower.ends_with(".localhost"))
{
return Err(format!("Hostname '{}' resolves to loopback", hostname));
}
if config.block_metadata_endpoints {
let metadata_hosts = [
"metadata.google.internal",
"metadata.google",
"169.254.169.254",
"metadata",
];
if metadata_hosts.iter().any(|h| lower == *h || lower.ends_with(h)) {
return Err(format!(
"Cloud metadata hostname '{}' is blocked",
hostname
));
}
}
if config.block_metadata_endpoints && lower == "instance-data" {
return Err("AWS instance metadata hostname is blocked".to_string());
}
let internal_tlds = [".internal", ".local", ".corp", ".home", ".lan"];
if internal_tlds.iter().any(|tld| lower.ends_with(tld)) {
return Err(format!(
"Hostname '{}' uses an internal TLD which is blocked",
hostname
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> SsrfConfig {
SsrfConfig::default()
}
#[test]
fn allows_public_url() {
assert!(validate_url("http://influxdb.example.com:8086/api/v3/query_sql", &default_config()).is_ok());
}
#[test]
fn allows_public_ip() {
assert!(validate_url("http://203.0.113.50:8086/query", &default_config()).is_ok());
}
#[test]
fn blocks_localhost() {
assert!(validate_url("http://localhost:8086/query", &default_config()).is_err());
assert!(validate_url("http://127.0.0.1:8086/query", &default_config()).is_err());
}
#[test]
fn blocks_private_ips() {
assert!(validate_url("http://10.0.0.1:8086/query", &default_config()).is_err());
assert!(validate_url("http://172.16.0.1:8086/query", &default_config()).is_err());
assert!(validate_url("http://192.168.1.1:8086/query", &default_config()).is_err());
}
#[test]
fn blocks_cloud_metadata() {
assert!(validate_url("http://169.254.169.254/latest/meta-data/", &default_config()).is_err());
assert!(validate_url("http://metadata.google.internal/computeMetadata/v1/", &default_config()).is_err());
}
#[test]
fn blocks_file_scheme() {
assert!(validate_url("file:///etc/passwd", &default_config()).is_err());
}
#[test]
fn blocks_internal_ports() {
assert!(validate_url("http://203.0.113.50:22/exploit", &default_config()).is_err());
assert!(validate_url("http://203.0.113.50:6379/CONFIG", &default_config()).is_err());
}
#[test]
fn blocks_ipv6_loopback() {
assert!(validate_ip_str("::1", &default_config()).is_err());
}
#[test]
fn blocks_ipv4_mapped_ipv6() {
assert!(validate_ip_str("::ffff:127.0.0.1", &default_config()).is_err());
}
#[test]
fn respects_allowlist() {
let mut config = default_config();
config.allowlist.insert("10.0.0.5".into());
assert!(validate_url("http://10.0.0.5:9090/api", &config).is_ok());
}
#[test]
fn blocks_internal_tlds() {
assert!(validate_url("http://database.internal:5432/query", &default_config()).is_err());
assert!(validate_url("http://redis.local:6379/", &default_config()).is_err());
}
}