#[cfg(feature = "webhook")]
pub(crate) fn validate_webhook_url(url: &str, require_https: bool) -> Result<(), String> {
use crate::i18n::t;
let parsed = url
.parse::<reqwest::Url>()
.map_err(|e| t("webhook-invalid-url", &[("reason", e.to_string())]))?;
if require_https && parsed.scheme() != "https" {
return Err(t("webhook-https-required", &[]));
}
let host_raw = parsed
.host_str()
.ok_or_else(|| t("webhook-missing-host", &[]))?;
let host = host_raw.trim_start_matches('[').trim_end_matches(']');
let lower_host = host.to_lowercase();
if lower_host == "localhost" || lower_host == "127.0.0.1" || lower_host == "::1" {
return Err(t("webhook-localhost-forbidden", &[]));
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if ip.is_loopback() {
return Err(t("webhook-loopback-forbidden", &[]));
}
if ip.is_unspecified() {
return Err(t("webhook-unspecified-forbidden", &[]));
}
match ip {
std::net::IpAddr::V4(v4) => {
if v4.is_private() {
return Err(t("webhook-private-forbidden", &[]));
}
if v4.is_link_local() {
return Err(t("webhook-link-local-forbidden", &[]));
}
}
std::net::IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
if v4.is_private() {
return Err(t("webhook-private-mapped-forbidden", &[])); }
if v4.is_link_local() {
return Err(t("webhook-link-local-mapped-forbidden", &[])); }
if v4.is_loopback() {
return Err(t("webhook-loopback-mapped-forbidden", &[])); }
if v4.is_unspecified() {
return Err(t("webhook-unspecified-mapped-forbidden", &[])); }
}
if v6.is_unique_local() {
return Err(t("webhook-unique-local-v6-forbidden", &[]));
}
let segs = v6.segments();
if (segs[0] & 0xffc0) == 0xfe80 {
return Err(t("webhook-link-local-v6-forbidden", &[]));
}
}
}
}
Ok(())
}
#[cfg(all(test, feature = "webhook"))]
mod tests {
use super::*;
#[test]
fn test_valid_https_url() {
assert!(validate_webhook_url("https://hooks.example.com/notify", true).is_ok());
}
#[test]
fn test_http_without_https_required() {
assert!(validate_webhook_url("http://hooks.example.com/notify", false).is_ok());
}
#[test]
fn test_http_rejected_when_https_required() {
match validate_webhook_url("http://hooks.example.com/notify", true) {
Err(msg) => assert!(msg.contains("HTTPS"), "expected HTTPS error, got: {}", msg),
Ok(_) => panic!("expected Err for HTTP URL with require_https=true"),
}
}
#[test]
fn test_invalid_url_parse_error() {
match validate_webhook_url("not a url", true) {
Err(msg) => assert!(
msg.contains("Invalid URL"),
"expected parse error, got: {}",
msg
),
Ok(_) => panic!("expected Err for invalid URL"),
}
}
#[test]
fn test_localhost_rejected() {
match validate_webhook_url("http://localhost/webhook", false) {
Err(msg) => assert!(msg.contains("localhost"), "got: {}", msg),
Ok(_) => panic!("expected Err for localhost"),
}
}
#[test]
fn test_localhost_case_insensitive() {
match validate_webhook_url("http://LocalHost/webhook", false) {
Err(msg) => assert!(msg.contains("localhost"), "got: {}", msg),
Ok(_) => panic!("expected Err for LocalHost"),
}
}
#[test]
fn test_loopback_ipv4_rejected() {
match validate_webhook_url("http://127.0.0.1/webhook", false) {
Err(msg) => assert!(
msg.contains("loopback") || msg.contains("Loopback IP") || msg.contains("回环"),
"got: {msg}"
),
Ok(_) => panic!("expected Err for 127.0.0.1"),
}
}
#[test]
fn test_private_ip_10_rejected() {
match validate_webhook_url("http://10.0.0.1/webhook", false) {
Err(msg) => assert!(msg.contains("Private IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for 10.x.x.x"),
}
}
#[test]
fn test_private_ip_172_rejected() {
match validate_webhook_url("http://172.16.0.1/webhook", false) {
Err(msg) => assert!(msg.contains("Private IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for 172.16.x.x"),
}
}
#[test]
fn test_private_ip_192_rejected() {
match validate_webhook_url("http://192.168.1.1/webhook", false) {
Err(msg) => assert!(msg.contains("Private IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for 192.168.x.x"),
}
}
#[test]
fn test_link_local_rejected() {
match validate_webhook_url("http://169.254.1.1/webhook", false) {
Err(msg) => assert!(msg.contains("Link-local IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for 169.254.x.x"),
}
}
#[test]
fn test_public_ip_accepted() {
assert!(validate_webhook_url("http://8.8.8.8/webhook", false).is_ok());
}
#[test]
fn test_public_domain_accepted() {
assert!(validate_webhook_url("https://hooks.slack.com/services/T00/B00/xxx", true).is_ok());
}
#[test]
fn test_missing_host_rejected() {
match validate_webhook_url("file:///dev/null", false) {
Err(msg) => assert!(msg.contains("missing a host name"), "got: {}", msg),
Ok(_) => panic!("expected Err for URL with no host"),
}
}
#[test]
fn test_url_with_query_params() {
assert!(
validate_webhook_url("https://example.com/webhook?token=abc&retry=1", true).is_ok()
);
}
#[test]
fn test_url_with_port() {
assert!(validate_webhook_url("https://example.com:8443/webhook", true).is_ok());
}
#[test]
fn test_empty_url() {
match validate_webhook_url("", false) {
Err(msg) => assert!(msg.contains("Invalid URL"), "got: {}", msg),
Ok(_) => panic!("expected Err for empty URL"),
}
}
#[test]
fn test_ipv4_mapped_ipv6_private_rejected() {
match validate_webhook_url("http://[::ffff:10.0.0.1]/webhook", false) {
Err(msg) => assert!(
msg.contains("IPv4-mapped") && msg.contains("Private IP"),
"expected IPv4-mapped private rejection, got: {}",
msg
),
Ok(_) => panic!("expected Err for IPv4-mapped private IPv6"),
}
}
#[test]
fn test_ipv4_mapped_ipv6_loopback_rejected() {
match validate_webhook_url("http://[::ffff:127.0.0.1]/webhook", false) {
Err(msg) => assert!(
msg.contains("IPv4-mapped") && msg.contains("Loopback"),
"got: {}",
msg
),
Ok(_) => panic!("expected Err for IPv4-mapped loopback"),
}
}
#[test]
fn test_ipv4_mapped_ipv6_link_local_rejected() {
match validate_webhook_url("http://[::ffff:169.254.1.1]/webhook", false) {
Err(msg) => assert!(
msg.contains("IPv4-mapped") && msg.contains("Link-local"),
"got: {}",
msg
),
Ok(_) => panic!("expected Err for IPv4-mapped link-local"),
}
}
#[test]
fn test_ipv4_mapped_ipv6_unspecified_rejected() {
match validate_webhook_url("http://[::ffff:0.0.0.0]/webhook", false) {
Err(msg) => assert!(
msg.contains("IPv4-mapped") && msg.contains("Unspecified"),
"got: {}",
msg
),
Ok(_) => panic!("expected Err for IPv4-mapped unspecified"),
}
}
#[test]
fn test_ipv4_unspecified_rejected() {
match validate_webhook_url("http://0.0.0.0/webhook", false) {
Err(msg) => assert!(msg.contains("Unspecified IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for 0.0.0.0"),
}
}
#[test]
fn test_ipv6_unspecified_rejected() {
match validate_webhook_url("http://[::]/webhook", false) {
Err(msg) => assert!(msg.contains("Unspecified IP"), "got: {}", msg),
Ok(_) => panic!("expected Err for ::"),
}
}
#[test]
fn test_ipv6_link_local_rejected() {
match validate_webhook_url("http://[fe80::1]/webhook", false) {
Err(msg) => assert!(msg.contains("Link-local IPv6"), "got: {}", msg),
Ok(_) => panic!("expected Err for fe80::1"),
}
}
#[test]
fn test_ipv4_mapped_public_accepted() {
assert!(
validate_webhook_url("http://[::ffff:8.8.8.8]/webhook", false).is_ok(),
"public IPv4-mapped IPv6 should be accepted"
);
}
#[test]
fn test_loopback_ipv4_127_0_0_2_rejected() {
match validate_webhook_url("http://127.0.0.2/webhook", false) {
Err(msg) => assert!(
msg.contains("loopback") || msg.contains("Loopback IP") || msg.contains("回环"),
"got: {msg}"
),
Ok(_) => panic!("expected Err for 127.0.0.2"),
}
}
#[test]
fn test_ipv6_unique_local_rejected() {
match validate_webhook_url("http://[fc00::1]/webhook", false) {
Err(msg) => assert!(msg.contains("Unique local IPv6"), "got: {}", msg),
Ok(_) => panic!("expected Err for fc00::1"),
}
}
}