use std::net::{IpAddr, ToSocketAddrs};
use solid_pod_rs::security::ssrf::{IpClass, SsrfPolicy};
use crate::error::SigError;
fn is_private_ip(ip: IpAddr) -> bool {
!matches!(SsrfPolicy::classify(ip), IpClass::Public)
}
pub fn assert_ssrf_safe(url: &str) -> Result<(), SigError> {
let parsed = url::Url::parse(url).map_err(|e| SigError::Url(e.to_string()))?;
match parsed.scheme() {
"http" | "https" => {}
other => {
return Err(SigError::SsrfBlocked(format!(
"scheme '{}' is not allowed",
other
)));
}
}
let host = parsed
.host_str()
.ok_or_else(|| SigError::SsrfBlocked("URL has no host".into()))?;
let port = parsed.port_or_known_default().unwrap_or(443);
let addr_str = format!("{host}:{port}");
let addrs: Vec<std::net::SocketAddr> = addr_str
.to_socket_addrs()
.map_err(|e| SigError::SsrfBlocked(format!("DNS resolution failed for '{}': {}", host, e)))?
.collect();
if addrs.is_empty() {
return Err(SigError::SsrfBlocked(format!(
"DNS resolution returned no addresses for '{}'",
host
)));
}
for addr in &addrs {
if is_private_ip(addr.ip()) {
return Err(SigError::SsrfBlocked(format!(
"resolved address {} for '{}' is in a private/reserved range",
addr.ip(),
host
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn rejects_loopback_ipv4() {
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255))));
}
#[test]
fn rejects_rfc1918() {
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
}
#[test]
fn rejects_link_local() {
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
}
#[test]
fn allows_routable_ipv4() {
assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
}
#[test]
fn rejects_ipv6_loopback() {
assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
}
#[test]
fn rejects_ipv6_link_local() {
assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
}
#[test]
fn allows_routable_ipv6() {
assert!(!is_private_ip(IpAddr::V6(Ipv6Addr::new(
0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
))));
}
#[test]
fn rejects_ipv4_compatible_loopback() {
let addr: Ipv6Addr = "::127.0.0.1".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"::127.0.0.1 must be detected as private (loopback)"
);
}
#[test]
fn rejects_ipv4_compatible_private() {
let addr: Ipv6Addr = "::10.0.0.1".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"::10.0.0.1 must be detected as private"
);
}
#[test]
fn rejects_ipv4_compatible_metadata() {
let addr: Ipv6Addr = "::169.254.169.254".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"::169.254.169.254 must be detected as private (link-local metadata)"
);
}
#[test]
fn allows_ipv4_compatible_public() {
let addr: Ipv6Addr = "::8.8.8.8".parse().unwrap();
assert!(
!is_private_ip(IpAddr::V6(addr)),
"::8.8.8.8 should not be flagged as private"
);
}
#[test]
fn rejects_6to4_private() {
let addr: Ipv6Addr = "2002:c0a8:0101::".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"2002:c0a8:0101:: (6to4 embedding 192.168.1.1) must be private"
);
}
#[test]
fn rejects_6to4_loopback() {
let addr: Ipv6Addr = "2002:7f00:0001::".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"2002:7f00:0001:: (6to4 embedding 127.0.0.1) must be private"
);
}
#[test]
fn rejects_6to4_metadata() {
let addr: Ipv6Addr = "2002:a9fe:a9fe::".parse().unwrap();
assert!(
is_private_ip(IpAddr::V6(addr)),
"2002:a9fe:a9fe:: (6to4 embedding 169.254.169.254) must be private"
);
}
#[test]
fn allows_6to4_public() {
let addr: Ipv6Addr = "2002:0808:0808::".parse().unwrap();
assert!(
!is_private_ip(IpAddr::V6(addr)),
"2002:0808:0808:: (6to4 embedding 8.8.8.8) should not be private"
);
}
#[test]
fn assert_ssrf_safe_rejects_localhost() {
let result = assert_ssrf_safe("http://127.0.0.1/latest/meta-data");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_metadata_endpoint() {
let result = assert_ssrf_safe("http://169.254.169.254/latest/meta-data");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_private_10() {
let result = assert_ssrf_safe("http://10.0.0.1/admin");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_private_172() {
let result = assert_ssrf_safe("http://172.16.0.1/internal");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_private_192() {
let result = assert_ssrf_safe("http://192.168.1.1/router");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_ftp_scheme() {
let result = assert_ssrf_safe("ftp://example.com/file");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
#[test]
fn assert_ssrf_safe_rejects_file_scheme() {
let result = assert_ssrf_safe("file:///etc/passwd");
assert!(matches!(result, Err(SigError::SsrfBlocked(_))));
}
}