use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use ipnet::{Ipv4Net, Ipv6Net};
use url::Url;
use crate::{IdprovaError, Result};
fn blocked_v4() -> Vec<Ipv4Net> {
[
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", ]
.iter()
.map(|s| s.parse().expect("static CIDR is valid"))
.collect()
}
fn blocked_v6() -> Vec<Ipv6Net> {
[
"::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", "::/128", ]
.iter()
.map(|s| s.parse().expect("static CIDR is valid"))
.collect()
}
const ALLOWED_SCHEMES: &[&str] = &["https", "http"];
pub fn validate_registry_url(raw_url: &str) -> Result<Url> {
let url = Url::parse(raw_url)
.map_err(|e| IdprovaError::Other(format!("invalid URL '{raw_url}': {e}")))?;
if !ALLOWED_SCHEMES.contains(&url.scheme()) {
return Err(IdprovaError::Other(format!(
"URL scheme '{}' is not permitted; only https/http are allowed",
url.scheme()
)));
}
let host = url
.host_str()
.ok_or_else(|| IdprovaError::Other(format!("URL has no host: {raw_url}")))?;
if let Ok(ip) = host.parse::<IpAddr>() {
check_ip_blocked(ip)?;
return Ok(url);
}
let port = url
.port()
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let addrs_str = format!("{host}:{port}");
let resolved: Vec<SocketAddr> = addrs_str
.to_socket_addrs()
.map_err(|e| IdprovaError::Other(format!("cannot resolve host '{host}': {e}")))?
.collect();
if resolved.is_empty() {
return Err(IdprovaError::Other(format!(
"host '{host}' resolved to no addresses"
)));
}
for addr in resolved {
check_ip_blocked(addr.ip())?;
}
Ok(url)
}
fn check_ip_blocked(ip: IpAddr) -> Result<()> {
match ip {
IpAddr::V4(v4) => {
for net in blocked_v4() {
if net.contains(&v4) {
return Err(IdprovaError::Other(format!(
"IP address {ip} is in blocked range {net} (SSRF prevention)"
)));
}
}
}
IpAddr::V6(v6) => {
for net in blocked_v6() {
if net.contains(&v6) {
return Err(IdprovaError::Other(format!(
"IP address {ip} is in blocked range {net} (SSRF prevention)"
)));
}
}
}
}
Ok(())
}
#[cfg(feature = "http")]
pub fn build_registry_client() -> std::result::Result<reqwest::Client, reqwest::Error> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::limited(5))
.https_only(true)
.user_agent(format!("idprova-client/{}", env!("CARGO_PKG_VERSION")))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reject_file_scheme() {
let result = validate_registry_url("file:///etc/passwd");
assert!(result.is_err(), "file:// must be rejected");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("scheme"), "expected scheme error, got: {msg}");
}
#[test]
fn test_reject_gopher_scheme() {
assert!(validate_registry_url("gopher://evil.example.com/").is_err());
}
#[test]
fn test_reject_ldap_scheme() {
assert!(validate_registry_url("ldap://10.0.0.1/dc=example").is_err());
}
#[test]
fn test_reject_ftp_scheme() {
assert!(validate_registry_url("ftp://ftp.example.com/file").is_err());
}
#[test]
fn test_reject_data_uri() {
assert!(validate_registry_url("data:text/html,<script>alert(1)</script>").is_err());
}
#[test]
fn test_reject_loopback_ipv4() {
assert!(validate_registry_url("http://127.0.0.1/api").is_err());
assert!(validate_registry_url("http://127.1.2.3/api").is_err());
}
#[test]
fn test_reject_private_class_a() {
assert!(validate_registry_url("https://10.0.0.1/api").is_err());
assert!(validate_registry_url("https://10.255.255.255/api").is_err());
}
#[test]
fn test_reject_private_class_b() {
assert!(validate_registry_url("https://172.16.0.1/api").is_err());
assert!(validate_registry_url("https://172.31.255.255/api").is_err());
}
#[test]
fn test_reject_private_class_c() {
assert!(validate_registry_url("https://192.168.1.1/api").is_err());
}
#[test]
fn test_reject_cloud_metadata() {
assert!(validate_registry_url("http://169.254.169.254/latest/meta-data/").is_err());
}
#[test]
fn test_reject_ipv6_loopback() {
assert!(validate_registry_url("https://[::1]/api").is_err());
}
#[test]
fn test_reject_ipv6_ula() {
assert!(validate_registry_url("https://[fc00::1]/api").is_err());
assert!(validate_registry_url("https://[fd00::1]/api").is_err());
}
#[test]
#[ignore = "requires DNS + network access"]
fn test_accept_public_https_url() {
let result = validate_registry_url("https://registry.idprova.dev");
assert!(
result.is_ok(),
"public HTTPS URL must be accepted: {:?}",
result.err()
);
}
#[test]
#[ignore = "requires DNS + network access"]
fn test_accept_public_https_with_path() {
assert!(validate_registry_url("https://registry.idprova.dev/v1/aid/test").is_ok());
}
#[test]
fn test_accept_public_ip() {
assert!(validate_registry_url("https://1.1.1.1/api").is_ok());
assert!(validate_registry_url("https://8.8.8.8/api").is_ok());
}
#[test]
fn test_reject_malformed_url() {
assert!(validate_registry_url("not a url at all").is_err());
assert!(validate_registry_url("").is_err());
}
}