use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::{Host, Url};
use crate::application::a2a::{FilterDenial, UrlFilter};
use crate::domain::a2a::A2aVersion;
#[derive(Debug, Clone, Copy, Default)]
pub struct SsrfDenylist;
impl SsrfDenylist {
pub const FILTER: Self = Self;
#[must_use]
pub fn is_denied_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => Self::is_denied_v4(v4),
IpAddr::V6(v6) => Self::is_denied_v6(v6),
}
}
fn is_denied_v4(v4: Ipv4Addr) -> bool {
let [a, b, c, _d] = v4.octets();
a == 0
|| a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 169 && b == 254)
|| (a == 192 && b == 0 && c == 2)
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| v4 == Ipv4Addr::BROADCAST
|| (a == 100 && (64..=127).contains(&b))
}
fn is_denied_v6(v6: Ipv6Addr) -> bool {
v6 == Ipv6Addr::UNSPECIFIED
|| v6 == Ipv6Addr::LOCALHOST
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8
}
fn check_url(url: &Url, version: A2aVersion) -> Result<(), FilterDenial> {
match url.scheme() {
"http" | "https" => {}
other => {
return Err(FilterDenial::UnsupportedScheme {
scheme: other.to_owned(),
});
}
}
if !url.username().is_empty() || url.password().is_some() {
return Err(FilterDenial::UserinfoNotAllowed);
}
let host = url.host().ok_or(FilterDenial::MissingHost)?;
match host {
Host::Domain(_) => {
}
Host::Ipv4(v4) => {
if Self::is_denied_v4(v4) {
return Err(FilterDenial::DeniedIpLiteral {
literal: v4.to_string(),
});
}
}
Host::Ipv6(v6) => {
if Self::is_denied_v6(v6) {
return Err(FilterDenial::DeniedIpLiteral {
literal: format!("[{v6}]"),
});
}
}
}
let path = url.path();
if path.starts_with("/.well-known/") {
return Err(FilterDenial::ReservedNamespace {
path: path.to_owned(),
});
}
let _ = version;
Ok(())
}
}
impl UrlFilter for SsrfDenylist {
fn check_at_config(&self, url: &Url, version: A2aVersion) -> Result<(), FilterDenial> {
Self::check_url(url, version)
}
fn check_at_delivery(
&self,
url: &Url,
resolved: IpAddr,
version: A2aVersion,
) -> Result<(), FilterDenial> {
Self::check_url(url, version)?;
if Self::is_denied_ip(resolved) {
return Err(FilterDenial::DeniedResolvedIp { ip: resolved });
}
if let IpAddr::V6(v6) = resolved
&& let Some(v4) = v6.to_ipv4_mapped()
&& Self::is_denied_v4(v4)
{
return Err(FilterDenial::DeniedResolvedIp { ip: IpAddr::V4(v4) });
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
clippy::panic,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use std::net::IpAddr;
use std::str::FromStr;
use url::Url;
use super::SsrfDenylist;
use crate::application::a2a::{FilterDenial, UrlFilter};
use crate::domain::a2a::A2aVersion;
const V1_0: A2aVersion = A2aVersion::V1_0;
fn url(s: &str) -> Url {
Url::from_str(s).expect("test fixture must be a valid URL")
}
fn ip(s: &str) -> IpAddr {
IpAddr::from_str(s).expect("test fixture must be a valid IP")
}
#[test]
fn is_denied_v4_covers_every_owasp_cited_range() {
let denied = [
"127.0.0.1",
"127.255.255.254",
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.31.255.255",
"192.168.0.1",
"192.168.255.255",
"169.254.0.1",
"169.254.169.254", "0.0.0.0",
"255.255.255.255",
];
for s in denied {
let addr = ip(s);
let IpAddr::V4(v4) = addr else {
panic!("{s} is not IPv4");
};
assert!(
SsrfDenylist::is_denied_v4(v4),
"{s} should be denied per the OWASP API7 list"
);
}
}
#[test]
fn is_denied_v4_allows_public_ranges() {
let allowed = [
"1.1.1.1", "8.8.8.8", "9.9.9.9", "93.184.216.34", "172.32.0.1", "172.15.0.1", "169.255.0.1", "169.253.0.1", "11.0.0.1", ];
for s in allowed {
let IpAddr::V4(v4) = ip(s) else {
panic!("{s} is not IPv4");
};
assert!(
!SsrfDenylist::is_denied_v4(v4),
"{s} should be allowed (public address)"
);
}
}
#[test]
fn is_denied_v6_covers_loopback_and_ula() {
let denied = ["::1", "::", "fe80::1", "fc00::1", "fd00::1", "2001:db8::1"];
for s in denied {
let IpAddr::V6(v6) = ip(s) else {
panic!("{s} is not IPv6");
};
assert!(
SsrfDenylist::is_denied_v6(v6),
"{s} should be denied per the IPv6 denylist"
);
}
}
#[test]
fn is_denied_v6_allows_public_ranges() {
let allowed = [
"2001:4860:4860::8888", "2606:4700:4700::1111", ];
for s in allowed {
let IpAddr::V6(v6) = ip(s) else {
panic!("{s} is not IPv6");
};
assert!(
!SsrfDenylist::is_denied_v6(v6),
"{s} should be allowed (public IPv6)"
);
}
}
#[test]
fn check_at_config_accepts_a_public_https_url() {
SsrfDenylist::FILTER
.check_at_config(&url("https://example.com/hook"), V1_0)
.expect("the call must succeed");
}
#[test]
fn check_at_config_refuses_loopback() {
let err = SsrfDenylist::FILTER
.check_at_config(&url("http://127.0.0.1/hook"), V1_0)
.expect_err("loopback must be denied");
assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
}
#[test]
fn check_at_config_refuses_cloud_metadata() {
let err = SsrfDenylist::FILTER
.check_at_config(&url("http://169.254.169.254/latest/meta-data/"), V1_0)
.expect_err("cloud metadata endpoint must be denied");
assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
}
#[test]
fn check_at_config_refuses_rfc_1918() {
for s in [
"http://10.0.0.1/",
"http://172.16.0.1/",
"http://192.168.1.1/",
] {
let err = SsrfDenylist::FILTER
.check_at_config(&url(s), V1_0)
.expect_err("RFC 1918 must be denied");
assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
}
}
#[test]
fn check_at_config_refuses_ipv6_loopback() {
let err = SsrfDenylist::FILTER
.check_at_config(&url("http://[::1]/hook"), V1_0)
.expect_err("IPv6 loopback must be denied");
assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
}
#[test]
fn check_at_config_refuses_non_http_schemes() {
for scheme in ["file", "gopher", "ftp", "ldap", "dict"] {
let u = url(&format!("{scheme}://example.com/x"));
let err = SsrfDenylist::FILTER
.check_at_config(&u, V1_0)
.expect_err(&format!("{scheme} must be denied"));
let matched = matches!(err, FilterDenial::UnsupportedScheme { .. });
assert!(matched, "{scheme} denial was {err:?}");
}
}
#[test]
fn check_at_config_refuses_userinfo() {
let err = SsrfDenylist::FILTER
.check_at_config(&url("https://attacker:pwn@example.com/"), V1_0)
.expect_err("userinfo must be denied");
assert_eq!(err, FilterDenial::UserinfoNotAllowed);
}
#[test]
fn check_at_config_refuses_well_known_path() {
let err = SsrfDenylist::FILTER
.check_at_config(
&url("https://example.com/.well-known/agent-card.json"),
V1_0,
)
.expect_err("well-known path must be denied");
assert_matches::assert_matches!(err, FilterDenial::ReservedNamespace { .. });
}
#[test]
fn check_at_delivery_refuses_a_dns_rebind_to_loopback() {
let err = SsrfDenylist::FILTER
.check_at_delivery(
&url("https://benign.example.com/hook"),
ip("127.0.0.1"),
V1_0,
)
.expect_err("DNS-rebind to loopback must be denied at delivery");
assert_eq!(
err,
FilterDenial::DeniedResolvedIp {
ip: ip("127.0.0.1"),
}
);
}
#[test]
fn check_at_delivery_refuses_a_dns_rebind_to_metadata() {
let err = SsrfDenylist::FILTER
.check_at_delivery(
&url("https://benign.example.com/hook"),
ip("169.254.169.254"),
V1_0,
)
.expect_err("DNS-rebind to cloud metadata must be denied at delivery");
assert_eq!(
err,
FilterDenial::DeniedResolvedIp {
ip: ip("169.254.169.254"),
}
);
}
#[test]
fn check_at_delivery_unwraps_ipv4_mapped_ipv6() {
let err = SsrfDenylist::FILTER
.check_at_delivery(
&url("https://benign.example.com/hook"),
ip("::ffff:127.0.0.1"),
V1_0,
)
.expect_err("IPv4-mapped loopback must be denied at delivery");
assert_eq!(
err,
FilterDenial::DeniedResolvedIp {
ip: ip("127.0.0.1"),
},
"the denial should name the IPv4 the operator recognises, not the wrapper"
);
}
#[test]
fn check_at_delivery_accepts_a_public_resolved_ip() {
SsrfDenylist::FILTER
.check_at_delivery(
&url("https://benign.example.com/hook"),
ip("93.184.216.34"),
V1_0,
)
.expect("the call must succeed");
}
#[test]
fn check_at_delivery_rechecks_the_url() {
let err = SsrfDenylist::FILTER
.check_at_delivery(&url("http://10.0.0.1/hook"), ip("93.184.216.34"), V1_0)
.expect_err("a tampered URL with a denied IP literal must still be denied");
assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
}
}