use std::net::{IpAddr, ToSocketAddrs};
pub fn safe_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
attempt.error("too many redirects")
} else if let Err(e) = validate_safe_url_blocking_resolved(attempt.url()) {
attempt.error(format!("redirect blocked: {e}"))
} else {
attempt.follow()
}
})
}
pub fn validate_safe_url(url: &url::Url) -> Result<(), String> {
let test_allow_loopback = std::env::var("CRW_ALLOW_LOOPBACK_FOR_TESTS").as_deref() == Ok("1");
const MAX_URL_LENGTH: usize = 2048;
if url.as_str().len() > MAX_URL_LENGTH {
return Err(format!(
"URL exceeds maximum length of {MAX_URL_LENGTH} characters"
));
}
if url.as_str().contains("%00") || url.as_str().contains('\0') {
return Err("URL contains null bytes".into());
}
if !matches!(url.scheme(), "http" | "https") {
return Err("Only http/https URLs are allowed".into());
}
let host = url
.host_str()
.ok_or_else(|| "URL has no host".to_string())?;
if !test_allow_loopback && is_blocked_host_name(host) {
return Err("host is not allowed".into());
}
if !test_allow_loopback
&& let Ok(ip) = host.parse::<IpAddr>()
&& is_blocked_ip(&ip)
{
return Err(format!("Access to {ip} is not allowed"));
}
let stripped = host.trim_start_matches('[').trim_end_matches(']');
if !test_allow_loopback
&& let Ok(ip) = stripped.parse::<IpAddr>()
&& is_blocked_ip(&ip)
{
return Err(format!("Access to {ip} is not allowed"));
}
Ok(())
}
pub async fn validate_safe_url_resolved(url: &url::Url) -> Result<(), String> {
validate_safe_url(url)?;
let test_allow_loopback = std::env::var("CRW_ALLOW_LOOPBACK_FOR_TESTS").as_deref() == Ok("1");
if test_allow_loopback {
return Ok(());
}
let host = url
.host_str()
.ok_or_else(|| "URL has no host".to_string())?;
let stripped = host.trim_start_matches('[').trim_end_matches(']');
if stripped.parse::<IpAddr>().is_ok() {
return Ok(());
}
let port = url
.port_or_known_default()
.ok_or_else(|| "URL has no resolvable port".to_string())?;
let addrs = tokio::net::lookup_host((stripped, port))
.await
.map_err(|_| "DNS resolution failed".to_string())?;
validate_resolved_ips(addrs.map(|addr| addr.ip()))
}
pub fn validate_safe_url_blocking_resolved(url: &url::Url) -> Result<(), String> {
validate_safe_url(url)?;
let test_allow_loopback = std::env::var("CRW_ALLOW_LOOPBACK_FOR_TESTS").as_deref() == Ok("1");
if test_allow_loopback {
return Ok(());
}
let host = url
.host_str()
.ok_or_else(|| "URL has no host".to_string())?;
let stripped = host.trim_start_matches('[').trim_end_matches(']');
if stripped.parse::<IpAddr>().is_ok() {
return Ok(());
}
let port = url
.port_or_known_default()
.ok_or_else(|| "URL has no resolvable port".to_string())?;
(stripped, port)
.to_socket_addrs()
.map_err(|_| "DNS resolution failed".to_string())
.and_then(|addrs| validate_resolved_ips(addrs.map(|addr| addr.ip())))
}
pub fn validate_resolved_ips<I>(ips: I) -> Result<(), String>
where
I: IntoIterator<Item = IpAddr>,
{
let mut saw_ip = false;
for ip in ips {
saw_ip = true;
if is_blocked_ip(&ip) {
return Err(format!("Access to {ip} is not allowed"));
}
}
if !saw_ip {
return Err("DNS resolution returned no addresses".into());
}
Ok(())
}
fn is_blocked_host_name(host: &str) -> bool {
let host_lower = host.to_lowercase();
let host_lower = host_lower.trim_end_matches('.');
host_lower == "localhost"
|| host_lower == "metadata.google.internal"
|| host_lower.ends_with(".localhost")
|| host_lower.ends_with(".localtest.me")
|| host_lower.ends_with(".lvh.me")
|| host_lower.ends_with(".nip.io")
|| host_lower.ends_with(".xip.io")
|| host_lower.ends_with(".sslip.io")
}
fn is_blocked_ipv4(v4: &std::net::Ipv4Addr) -> bool {
let [a, b, _, _] = v4.octets();
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || (a == 100 && (64..=127).contains(&b)) || a == 0
|| a >= 224 || (a == 192 && b == 0)
|| (a == 198 && (b == 18 || b == 19 || b == 51))
|| (a == 203 && b == 0)
}
fn is_blocked_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_blocked_ipv4(v4),
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || v6.to_ipv4_mapped().is_some_and(|v4| is_blocked_ipv4(&v4))
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xff00) == 0xff00
|| (v6.segments()[0] & 0xff00) == 0x0200
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> url::Url {
url::Url::parse(s).unwrap()
}
#[test]
fn allows_normal_urls() {
assert!(validate_safe_url(&url("https://example.com")).is_ok());
assert!(validate_safe_url(&url("http://example.com/path")).is_ok());
}
#[test]
fn blocks_non_http_schemes() {
assert!(validate_safe_url(&url("ftp://example.com")).is_err());
assert!(validate_safe_url(&url("file:///etc/passwd")).is_err());
}
#[test]
fn blocks_localhost() {
assert!(validate_safe_url(&url("http://localhost")).is_err());
assert!(validate_safe_url(&url("http://localhost:8080")).is_err());
assert!(validate_safe_url(&url("http://127.0.0.1")).is_err());
assert!(validate_safe_url(&url("http://127.0.0.1:9999")).is_err());
assert!(validate_safe_url(&url("http://metadata.google.internal")).is_err());
assert!(validate_safe_url(&url("http://127.0.0.1.nip.io")).is_err());
}
#[test]
fn blocks_private_ips() {
assert!(validate_safe_url(&url("http://10.0.0.1")).is_err());
assert!(validate_safe_url(&url("http://172.16.0.1")).is_err());
assert!(validate_safe_url(&url("http://192.168.1.1")).is_err());
}
#[test]
fn blocks_link_local() {
assert!(validate_safe_url(&url("http://169.254.169.254/latest/meta-data/")).is_err());
}
#[test]
fn blocks_zero_ip() {
assert!(validate_safe_url(&url("http://0.0.0.0")).is_err());
assert!(validate_safe_url(&url("http://100.64.0.1")).is_err());
assert!(validate_safe_url(&url("http://224.0.0.1")).is_err());
}
#[test]
fn blocks_ipv6_loopback() {
assert!(validate_safe_url(&url("http://[::1]")).is_err());
}
#[test]
fn blocks_ipv4_mapped_ipv6() {
assert!(validate_safe_url(&url("http://[::ffff:127.0.0.1]")).is_err());
assert!(validate_safe_url(&url("http://[::ffff:169.254.169.254]")).is_err());
assert!(validate_safe_url(&url("http://[::ffff:10.0.0.1]")).is_err());
}
#[test]
fn blocks_ipv6_link_local() {
assert!(validate_safe_url(&url("http://[fe80::1]")).is_err());
}
#[test]
fn blocks_ipv6_ula() {
assert!(validate_safe_url(&url("http://[fc00::1]")).is_err());
assert!(validate_safe_url(&url("http://[fd00::1]")).is_err());
}
#[test]
fn blocks_extremely_long_urls() {
let long = format!("https://example.com/{}", "a".repeat(3000));
assert!(validate_safe_url(&url(&long)).is_err());
}
#[test]
fn allows_url_within_length_limit() {
let ok = format!("https://example.com/{}", "a".repeat(1000));
assert!(validate_safe_url(&url(&ok)).is_ok());
}
#[test]
fn safe_redirect_policy_exists() {
let _policy = super::safe_redirect_policy();
}
#[test]
fn resolved_ips_fail_closed_for_private_or_empty_answers() {
assert!(validate_resolved_ips([IpAddr::from([93, 184, 216, 34])]).is_ok());
assert!(
validate_resolved_ips([
IpAddr::from([93, 184, 216, 34]),
IpAddr::from([10, 0, 0, 1])
])
.is_err()
);
assert!(validate_resolved_ips([]).is_err());
}
#[test]
fn blocking_resolved_validation_rejects_denied_literal_ips() {
assert!(validate_safe_url_blocking_resolved(&url("http://169.254.169.254")).is_err());
assert!(validate_safe_url_blocking_resolved(&url("http://127.0.0.1")).is_err());
assert!(validate_safe_url_blocking_resolved(&url("http://[::1]")).is_err());
}
}