use std::net::{IpAddr, Ipv4Addr};
use url::Host;
use crate::error::{Error, Result};
pub(crate) fn validate_url(url: &str) -> Result<String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(Error::BadToolArgs {
name: "WebFetch".into(),
message: "URL must start with http:// or https://".into(),
});
}
let parsed = url::Url::parse(url).map_err(|e| Error::BadToolArgs {
name: "WebFetch".into(),
message: format!("invalid URL: {e}"),
})?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(Error::BadToolArgs {
name: "WebFetch".into(),
message: "URL must start with http:// or https://".into(),
});
}
if !parsed.username().is_empty() {
return Err(Error::BadToolArgs {
name: "WebFetch".into(),
message: "SSRF protection: userinfo in URL is not allowed".into(),
});
}
match parsed.host() {
Some(Host::Ipv4(v4)) => {
let ip = IpAddr::V4(v4);
if is_private_ip(ip) {
return Err(reject(ip));
}
}
Some(Host::Ipv6(v6)) => {
if let Some(v4) = v6.to_ipv4_mapped() {
let ip = IpAddr::V4(v4);
if is_private_ip(ip) {
return Err(reject(ip));
}
} else if is_private_ip(IpAddr::V6(v6)) {
return Err(reject(IpAddr::V6(v6)));
}
}
Some(Host::Domain(d)) => {
let host = d.to_ascii_lowercase();
let host = host.strip_suffix('.').unwrap_or(host.as_str());
if host == "localhost"
|| host.ends_with(".localhost")
|| host == "metadata.google.internal"
{
return Err(Error::BadToolArgs {
name: "WebFetch".into(),
message: format!("SSRF protection: host '{host}' is not allowed"),
});
}
if let Ok(ip) = host.parse::<IpAddr>() {
if is_private_ip(ip) {
return Err(reject(ip));
}
} else if let Some(ip) = parse_lenient_ipv4(host) {
if is_private_ip(ip) {
return Err(reject(ip));
}
}
}
None => {
return Err(Error::BadToolArgs {
name: "WebFetch".into(),
message: "SSRF protection: URL has no host".into(),
});
}
}
Ok(url.to_string())
}
pub(crate) fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast()
|| v4.is_unspecified()
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || v6.is_multicast()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
fn reject(ip: IpAddr) -> Error {
Error::BadToolArgs {
name: "WebFetch".into(),
message: format!("SSRF protection: IP address '{ip}' is not routable"),
}
}
fn parse_lenient_ipv4(s: &str) -> Option<IpAddr> {
let parts: Vec<&str> = s.split('.').collect();
if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty()) {
return None;
}
let mut value: u32 = 0;
for (i, part) in parts.iter().enumerate() {
let n = parse_lenient_part(part)?;
if i + 1 == parts.len() {
let remaining_bytes = 4 - i;
let max_last = if remaining_bytes >= 4 {
u32::MAX
} else {
(1u32 << (8 * remaining_bytes)) - 1
};
if n > max_last {
return None;
}
value |= n;
} else {
if n > 0xFF {
return None;
}
value |= n << (8 * (3 - i));
}
}
Some(IpAddr::V4(Ipv4Addr::from(value)))
}
fn parse_lenient_part(part: &str) -> Option<u32> {
if let Some(hex) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")) {
if hex.is_empty() {
return None;
}
return u32::from_str_radix(hex, 16).ok();
}
let first = part.as_bytes().first()?;
if !first.is_ascii_digit() {
return None;
}
if part.len() > 1 && part.starts_with('0') {
return u32::from_str_radix(&part[1..], 8).ok();
}
part.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}
#[test]
fn parse_lenient_ipv4_decimal_single_part() {
assert_eq!(parse_lenient_ipv4("2130706433"), Some(v4(127, 0, 0, 1)));
assert_eq!(
parse_lenient_ipv4("2852039166"),
Some(v4(169, 254, 169, 254))
);
assert_eq!(parse_lenient_ipv4("3232235521"), Some(v4(192, 168, 0, 1)));
assert_eq!(parse_lenient_ipv4("0"), Some(v4(0, 0, 0, 0)));
assert_eq!(
parse_lenient_ipv4("4294967295"),
Some(v4(255, 255, 255, 255))
);
}
#[test]
fn parse_lenient_ipv4_hex() {
assert_eq!(parse_lenient_ipv4("0x7f000001"), Some(v4(127, 0, 0, 1)));
assert_eq!(
parse_lenient_ipv4("0xa9fea9fe"),
Some(v4(169, 254, 169, 254))
);
assert_eq!(parse_lenient_ipv4("0X7F000001"), Some(v4(127, 0, 0, 1)));
}
#[test]
fn parse_lenient_ipv4_short_form() {
assert_eq!(parse_lenient_ipv4("127.1"), Some(v4(127, 0, 0, 1)));
assert_eq!(parse_lenient_ipv4("127.0.1"), Some(v4(127, 0, 0, 1)));
}
#[test]
fn parse_lenient_ipv4_octal() {
assert_eq!(parse_lenient_ipv4("0177.0.0.1"), Some(v4(127, 0, 0, 1)));
assert_eq!(parse_lenient_ipv4("00"), Some(v4(0, 0, 0, 0)));
}
#[test]
fn parse_lenient_ipv4_malformed() {
assert_eq!(parse_lenient_ipv4(""), None);
assert_eq!(parse_lenient_ipv4("256.1.1.1"), None);
assert_eq!(parse_lenient_ipv4("1.2.65536"), None);
assert_eq!(parse_lenient_ipv4("0x"), None);
assert_eq!(parse_lenient_ipv4("1.2.3.4.5"), None);
assert_eq!(parse_lenient_ipv4("abc"), None);
assert_eq!(parse_lenient_ipv4("127.0.0.999"), None);
}
#[test]
fn validate_url_blocks_lenient_ssrf_spellings() {
for url in [
"http://2130706433/",
"http://0x7f000001/",
"http://127.1/",
"http://127.0.1/",
"http://0177.0.0.1/",
"http://2852039166/latest/meta-data/",
"http://0xa9fea9fe/",
"http://3232235521/",
"http://0/",
"http://4294967295/",
] {
assert!(validate_url(url).is_err(), "expected {url} to be rejected");
}
}
#[test]
fn validate_url_still_accepts_hostnames() {
assert!(validate_url("http://example.com").is_ok());
assert!(validate_url("http://example.com:8080/path").is_ok());
}
#[test]
fn validate_url_blocks_userinfo_bypass() {
assert!(validate_url("http://user@127.0.0.1/").is_err());
assert!(validate_url("http://user:pass@example.com/").is_err());
}
#[test]
fn validate_url_blocks_fragment_query_bypass() {
assert!(validate_url("http://127.0.0.1#@evil.com/").is_err());
assert!(validate_url("http://127.0.0.1?x/").is_err());
}
#[test]
fn validate_url_blocks_backslash_bypass() {
assert!(validate_url("http://127.0.0.1\\@evil.com/").is_err());
}
#[test]
fn validate_url_blocks_ipv4_mapped_ipv6() {
assert!(validate_url("http://[::ffff:127.0.0.1]/").is_err());
assert!(validate_url("http://[::ffff:169.254.169.254]/").is_err());
assert!(validate_url("http://[::ffff:8.8.8.8]/").is_ok());
}
#[test]
fn validate_url_blocks_trailing_dot_literals() {
assert!(validate_url("http://127.0.0.1./").is_err());
assert!(validate_url("http://2130706433./").is_err());
assert!(validate_url("http://localhost./").is_err());
}
#[test]
fn validate_url_blocks_loopback_with_port() {
assert!(validate_url("http://127.0.0.1:8080/path").is_err());
}
#[test]
fn validate_url_still_accepts_public_targets() {
assert!(validate_url("http://example.com").is_ok());
assert!(validate_url("http://example.com:8080/path").is_ok());
assert!(validate_url("http://[2001:db8::1]/path").is_ok());
assert!(validate_url("https://example.com/x?y=1#z").is_ok());
assert!(validate_url("http://example.com./").is_ok());
}
#[test]
fn validate_url_rejects_unparseable() {
assert!(validate_url("http://").is_err());
assert!(validate_url("http://exa mple.com/").is_err());
}
}