use anyhow::Result;
use std::net::IpAddr;
use std::str::FromStr;
pub fn validate_url(url: &str, strict_https: bool) -> Result<()> {
if url.is_empty() {
anyhow::bail!("URL cannot be empty");
}
let parsed = url::Url::parse(url).map_err(|_| anyhow::anyhow!("invalid URL: {}", url))?;
match parsed.scheme() {
"http" | "https" => {}
"ftp" | "file" | "data" => {
anyhow::bail!(
"URL scheme '{}' not allowed (use http or https)",
parsed.scheme()
)
}
_ => {
anyhow::bail!("unsupported URL scheme: {}", parsed.scheme())
}
}
if strict_https && parsed.scheme() != "https" {
if let Some(host) = parsed.host_str() {
if host != "localhost" && !host.starts_with("localhost:") {
anyhow::bail!("webhooks must use https:// (not http://) except for localhost");
}
}
}
if let Some(host) = parsed.host_str() {
is_public_ip(host)?;
} else {
anyhow::bail!("URL missing host");
}
Ok(())
}
fn is_public_ip(host: &str) -> Result<()> {
if host == "localhost" || host.starts_with("localhost:") {
return Ok(()); }
let host_to_parse = if host.starts_with('[') {
if let Some(bracket_idx) = host.find(']') {
&host[1..bracket_idx]
} else {
host }
} else {
host
};
match IpAddr::from_str(host_to_parse) {
Ok(ip) => {
if is_private_ip(&ip) {
anyhow::bail!("URL uses private/internal IP address: {}", ip);
}
Ok(())
}
Err(_) => {
Ok(())
}
}
}
fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(addr) => {
addr.is_private() || addr.is_loopback() || addr.is_link_local() || addr.is_broadcast() || addr.is_unspecified() }
IpAddr::V6(addr) => {
addr.is_loopback() || addr.is_unspecified() || is_ipv6_private(addr)
|| is_ipv6_link_local(addr)
}
}
}
fn is_ipv6_private(addr: &std::net::Ipv6Addr) -> bool {
let segments = addr.segments();
(segments[0] & 0xfe00) == 0xfc00
}
fn is_ipv6_link_local(addr: &std::net::Ipv6Addr) -> bool {
let segments = addr.segments();
(segments[0] & 0xffc0) == 0xfe80
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_public_urls() {
assert!(validate_url("https://example.com", false).is_ok());
assert!(validate_url("https://api.github.com", false).is_ok());
assert!(validate_url("http://example.com", false).is_ok());
assert!(validate_url("https://hooks.slack.com/services/xyz", false).is_ok());
}
#[test]
fn test_localhost_allowed_for_http() {
assert!(validate_url("http://localhost:8080", false).is_ok());
assert!(validate_url("http://localhost", false).is_ok());
}
#[test]
fn test_https_required_for_webhooks() {
assert!(validate_url("https://example.com", true).is_ok());
assert!(validate_url("http://example.com", true).is_err()); assert!(validate_url("http://localhost:8080", true).is_ok()); }
#[test]
fn test_invalid_private_ips() {
assert!(validate_url("http://127.0.0.1", false).is_err());
assert!(validate_url("http://192.168.1.1", false).is_err());
assert!(validate_url("http://10.0.0.1", false).is_err());
assert!(validate_url("http://172.16.0.1", false).is_err());
assert!(validate_url("http://169.254.1.1", false).is_err());
}
#[test]
fn test_ipv6_loopback_rejected() {
assert!(validate_url("http://[::1]", false).is_err());
}
#[test]
fn test_invalid_schemes() {
assert!(validate_url("file:///etc/passwd", false).is_err());
assert!(validate_url("ftp://example.com", false).is_err());
assert!(validate_url("data:text/plain,hello", false).is_err());
}
#[test]
fn test_malformed_urls() {
assert!(validate_url("not a url", false).is_err());
assert!(validate_url("", false).is_err());
}
}