use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
use ureq::config::Config;
use ureq::http::Uri;
use ureq::unversioned::resolver::{DefaultResolver, ResolvedSocketAddrs, Resolver};
use ureq::unversioned::transport::NextTimeout;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UrlError {
Empty,
BadScheme(String),
MissingHost,
Credentials,
Blocked(String),
Unresolvable(String),
}
impl std::fmt::Display for UrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "empty URL"),
Self::BadScheme(s) => {
write!(f, "unsupported scheme '{s}' (only http/https allowed)")
}
Self::MissingHost => write!(f, "URL has no host"),
Self::Credentials => write!(f, "URLs with embedded credentials are not allowed"),
Self::Blocked(h) => {
write!(
f,
"host '{h}' resolves to a blocked (private/loopback) address"
)
}
Self::Unresolvable(h) => write!(f, "host '{h}' could not be resolved"),
}
}
}
impl std::error::Error for UrlError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SafeUrl {
pub scheme: String,
pub host: String,
pub port: u16,
pub authority: String,
pub normalized: String,
}
pub fn validate(raw: &str) -> Result<SafeUrl, UrlError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(UrlError::Empty);
}
let Some((scheme_raw, rest)) = trimmed.split_once("://") else {
let head: String = trimmed.chars().take(12).collect();
return Err(UrlError::BadScheme(head));
};
let scheme = scheme_raw.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return Err(UrlError::BadScheme(scheme));
}
let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..auth_end];
let path = &rest[auth_end..];
if authority.is_empty() {
return Err(UrlError::MissingHost);
}
if authority.contains('@') {
return Err(UrlError::Credentials);
}
let (host, port) = split_host_port(authority, &scheme)?;
if host.is_empty() {
return Err(UrlError::MissingHost);
}
Ok(SafeUrl {
scheme: scheme.clone(),
host,
port,
authority: authority.to_string(),
normalized: format!("{scheme}://{authority}{path}"),
})
}
fn split_host_port(authority: &str, scheme: &str) -> Result<(String, u16), UrlError> {
let default_port = if scheme == "https" { 443 } else { 80 };
if let Some(stripped) = authority.strip_prefix('[') {
let Some(end) = stripped.find(']') else {
return Err(UrlError::MissingHost);
};
let host = stripped[..end].to_string();
let port = match stripped[end + 1..].strip_prefix(':') {
Some(p) => p.parse().map_err(|_| UrlError::MissingHost)?,
None => default_port,
};
return Ok((host, port));
}
match authority.rsplit_once(':') {
Some((host, port_str))
if !port_str.is_empty() && port_str.bytes().all(|b| b.is_ascii_digit()) =>
{
let port = port_str.parse().map_err(|_| UrlError::MissingHost)?;
Ok((host.to_string(), port))
}
_ => Ok((authority.to_string(), default_port)),
}
}
impl SafeUrl {
pub fn ensure_resolves_safely(&self) -> Result<(), UrlError> {
if let Ok(ip) = self.host.parse::<IpAddr>() {
return if ip_is_blocked(ip) {
Err(UrlError::Blocked(self.host.clone()))
} else {
Ok(())
};
}
let addrs = (self.host.as_str(), self.port)
.to_socket_addrs()
.map_err(|_| UrlError::Unresolvable(self.host.clone()))?;
let mut resolved_any = false;
for addr in addrs {
resolved_any = true;
if ip_is_blocked(addr.ip()) {
return Err(UrlError::Blocked(self.host.clone()));
}
}
if resolved_any {
Ok(())
} else {
Err(UrlError::Unresolvable(self.host.clone()))
}
}
}
#[cfg(feature = "test-support")]
fn loopback_allowed(ip: IpAddr) -> bool {
ip == IpAddr::V4(Ipv4Addr::LOCALHOST)
&& std::env::var("LEAN_CTX_URL_GUARD_ALLOW_LOOPBACK").is_ok_and(|v| v == "1")
}
#[cfg(not(feature = "test-support"))]
fn loopback_allowed(_ip: IpAddr) -> bool {
false
}
pub fn ip_is_blocked(ip: IpAddr) -> bool {
if loopback_allowed(ip) {
return false;
}
match ip {
IpAddr::V4(v4) => v4_is_blocked(v4),
IpAddr::V6(v6) => {
if let Some(mapped) = v6.to_ipv4_mapped() {
return v4_is_blocked(mapped);
}
let v4 = |hi: u16, lo: u16| Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo));
let segments = v6.segments();
if segments[4] & !0x0300 == 0
&& segments[5] == 0x5efe
&& v4_is_blocked(v4(segments[6], segments[7]))
{
return true;
}
match segments {
[0x64, 0xff9b, 0, 0, 0, 0, hi, lo]
| [0x2002, hi, lo, ..]
| [0, 0, 0, 0, 0 | 0xffff, 0, hi, lo] => return v4_is_blocked(v4(hi, lo)),
[0x64, 0xff9b, 1, ..] => return true,
[0x2001, 0, shi, slo, _, _, chi, clo] => {
return v4_is_blocked(v4(shi, slo)) || v4_is_blocked(v4(!chi, !clo));
}
_ => {}
}
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| is_unique_local_v6(v6)
|| is_link_local_v6(v6)
|| is_site_local_v6(v6)
}
}
}
#[derive(Debug, Default)]
pub struct SsrfSafeResolver {
inner: DefaultResolver,
}
impl Resolver for SsrfSafeResolver {
fn resolve(
&self,
uri: &Uri,
config: &Config,
timeout: NextTimeout,
) -> Result<ResolvedSocketAddrs, ureq::Error> {
let addrs = self.inner.resolve(uri, config, timeout)?;
if addrs.iter().any(|addr| ip_is_blocked(addr.ip())) {
return Err(ureq::Error::HostNotFound);
}
Ok(addrs)
}
}
fn v4_is_blocked(v4: Ipv4Addr) -> bool {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.is_documentation()
|| o[0] == 0
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|| o[0] >= 224
|| (o[0] == 198 && (o[1] & 0xfe) == 18)
}
fn is_unique_local_v6(v6: Ipv6Addr) -> bool {
(v6.segments()[0] & 0xfe00) == 0xfc00
}
fn is_site_local_v6(v6: Ipv6Addr) -> bool {
(v6.segments()[0] & 0xffc0) == 0xfec0
}
fn is_link_local_v6(v6: Ipv6Addr) -> bool {
(v6.segments()[0] & 0xffc0) == 0xfe80
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_https_with_path() {
let u = validate("https://example.com/foo/bar?x=1").unwrap();
assert_eq!(u.scheme, "https");
assert_eq!(u.host, "example.com");
assert_eq!(u.port, 443);
assert_eq!(u.authority, "example.com");
assert_eq!(u.normalized, "https://example.com/foo/bar?x=1");
}
#[test]
fn validates_http_with_explicit_port() {
let u = validate("http://example.com:8080/p").unwrap();
assert_eq!(u.port, 8080);
assert_eq!(u.authority, "example.com:8080");
}
#[test]
fn validates_ipv6_literal_with_port() {
let u = validate("https://[2606:4700::1111]:8443/p").unwrap();
assert_eq!(u.host, "2606:4700::1111");
assert_eq!(u.port, 8443);
}
#[test]
fn rejects_non_http_scheme() {
assert!(matches!(
validate("ftp://example.com"),
Err(UrlError::BadScheme(_))
));
assert!(matches!(
validate("file:///etc/passwd"),
Err(UrlError::BadScheme(_))
));
}
#[test]
fn rejects_empty_and_credentials() {
assert_eq!(validate(" "), Err(UrlError::Empty));
assert_eq!(
validate("https://user:pass@example.com"),
Err(UrlError::Credentials)
);
}
#[test]
fn blocks_loopback_and_private_v4() {
for ip in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"] {
assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
}
}
#[test]
fn blocks_metadata_and_cgnat() {
assert!(ip_is_blocked("169.254.169.254".parse().unwrap()));
assert!(ip_is_blocked("100.64.0.1".parse().unwrap()));
assert!(ip_is_blocked("0.0.0.0".parse().unwrap()));
}
#[test]
fn allows_public_v4_and_v6() {
assert!(!ip_is_blocked("8.8.8.8".parse().unwrap()));
assert!(!ip_is_blocked("1.1.1.1".parse().unwrap()));
assert!(!ip_is_blocked("2606:4700:4700::1111".parse().unwrap()));
}
#[test]
fn blocks_v6_internal_ranges() {
assert!(ip_is_blocked("::1".parse().unwrap()));
assert!(ip_is_blocked("fe80::1".parse().unwrap()));
assert!(ip_is_blocked("fc00::1".parse().unwrap()));
assert!(ip_is_blocked("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn blocks_v6_transition_addrs_embedding_internal_v4() {
for ip in [
"64:ff9b::a9fe:a9fe",
"64:ff9b::7f00:1",
"64:ff9b::a00:1",
"64:ff9b:1::a9fe:a9fe",
"2002:a9fe:a9fe::1",
"2002:7f00:1::1",
"2002:a00:1::1",
"2001:0:a9fe:a9fe::1",
"2001:0:808:808:0:0:5601:5601",
"2001:0:808:808:0:0:80ff:fffe",
"2001:0:808:808:0:0:f5ff:fffe",
"::a9fe:a9fe",
"::7f00:1",
"::a00:1",
"::",
] {
assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
let u = validate(&format!("http://[{ip}]/")).unwrap();
assert!(
matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
"{ip} must be refused by ensure_resolves_safely"
);
}
}
#[test]
fn allows_v6_transition_addrs_embedding_public_v4() {
for ip in [
"64:ff9b::808:808",
"2002:808:808::1",
"2001:0:808:808:0:0:fefe:fefe",
"::808:808",
] {
assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
}
}
#[test]
fn blocks_siit_isatap_multicast_site_local_and_benchmarking() {
for ip in [
"::ffff:0:a9fe:a9fe",
"::ffff:0:7f00:1",
"::ffff:0:a00:1",
"2001:db8::5efe:a9fe:a9fe",
"2606:4700::200:5efe:7f00:1",
"2606:4700::5efe:a00:1",
"fe80::200:5efe:c0a8:101",
"ff02::1",
"ff05::2",
"ff0e::1",
"fec0::1",
"feff::1",
"224.0.0.1",
"239.255.255.250",
"240.0.0.1",
"198.18.0.1",
"198.19.255.255",
] {
assert!(ip_is_blocked(ip.parse().unwrap()), "{ip} must be blocked");
let u = validate(&format!(
"http://{}/",
if ip.contains(':') {
format!("[{ip}]")
} else {
ip.to_string()
}
))
.unwrap();
assert!(
matches!(u.ensure_resolves_safely(), Err(UrlError::Blocked(_))),
"{ip} must be refused by ensure_resolves_safely"
);
}
}
#[test]
fn allows_public_neighbours_of_the_new_blocked_ranges() {
for ip in [
"::ffff:0:808:808",
"2606:4700::5efe:808:808",
"2606:4700::200:5efe:101:101",
"2001:4860:4860::8888",
"fe00::1",
"198.17.255.255",
"198.20.0.1",
"223.255.255.254",
] {
assert!(!ip_is_blocked(ip.parse().unwrap()), "{ip} must be allowed");
}
}
#[test]
fn ensure_resolves_safely_rejects_literal_loopback() {
let u = validate("http://127.0.0.1/").unwrap();
assert!(matches!(
u.ensure_resolves_safely(),
Err(UrlError::Blocked(_))
));
}
#[test]
fn ensure_resolves_safely_allows_literal_public_ip() {
let u = validate("http://8.8.8.8/").unwrap();
assert!(u.ensure_resolves_safely().is_ok());
}
fn no_timeout() -> NextTimeout {
use ureq::unversioned::transport::time::Duration;
NextTimeout {
after: Duration::NotHappening,
reason: ureq::Timeout::Global,
}
}
#[test]
fn ssrf_safe_resolver_blocks_loopback_hostname() {
let resolver = SsrfSafeResolver::default();
let uri: Uri = "http://localhost:80/".parse().unwrap();
let config = Config::default();
assert!(resolver.resolve(&uri, &config, no_timeout()).is_err());
}
#[test]
fn ssrf_safe_resolver_allows_literal_public_ip() {
let resolver = SsrfSafeResolver::default();
let uri: Uri = "http://8.8.8.8:80/".parse().unwrap();
let config = Config::default();
assert!(resolver.resolve(&uri, &config, no_timeout()).is_ok());
}
}