protocol_droid/adapters/secondary/a2a/ssrf_denylist.rs
1//! The default SSRF denylist for A2A push-notification URLs.
2//!
3//! # What this is
4//!
5//! The production implementation of [`UrlFilter`].
6//! It refuses every URL the OWASP API Security Top 10 (2023) API7
7//! guidance names as a known SSRF primitive, in the order the spec names
8//! them. A private-network deployment that needs to deliver to
9//! `10.0.0.0/8` writes its own impl and passes it in; the production
10//! `SsrfDenylist` is the safe default, not the only option.
11//!
12//! # The ranges, in one place
13//!
14//! | Family | Range | Why denied |
15//! | --- | --- | --- |
16//! | IPv4 loopback | `127.0.0.0/8` | The local machine. |
17//! | IPv4 link-local | `169.254.0.0/16` | Includes the cloud metadata endpoint at `169.254.169.254`. |
18//! | IPv4 private | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | RFC 1918. |
19//! | IPv4 unspecified | `0.0.0.0/8` | The "any" address; resolving here dials the local machine. |
20//! | IPv4 broadcast | `255.255.255.255/32` | Limited broadcast. |
21//! | IPv4 documentation | `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24` | RFC 5737. |
22//! | IPv4 this network | `0.0.0.0/8` | "This network". |
23//! | IPv6 loopback | `::1/128` | The local machine, IPv6. |
24//! | IPv6 unspecified | `::/128` | The "any" address, IPv6. |
25//! | IPv6 link-local | `fe80::/10` | RFC 4291. |
26//! | IPv6 ULA | `fc00::/7` | RFC 4193 — the IPv6 RFC 1918. |
27//! | IPv6 documentation | `2001:db8::/32` | RFC 3849. |
28//! | IPv4-mapped IPv6 | `::ffff:0:0/96` | The IPv4 range, wrapped — checked after the IPv4 check. |
29//!
30//! # Schemes
31//!
32//! `http` and `https` are the only accepted schemes. `file://`, `gopher://`,
33//! `dict://`, `ftp://`, `ldap://`, and the rest of the URL-scheme zoo are
34//! either impossible (no socket) or obvious SSRF primitives. The
35//! `javascript:` and `data:` schemes are not URLs the `url` crate parses
36//! as URLs anyway.
37//!
38//! # What the denylist deliberately does *not* do
39//!
40//! - It does not resolve DNS. A hostname that resolves to a denied IP at
41//! delivery time is still rejected — by `check_at_delivery`, on the
42//! resolved IP, not here. The split is the DNS-rebinding defense.
43//! - It does not follow redirects. An HTTP 302 to `127.0.0.1` is the
44//! delivery adapter's problem to refuse; the denylist would have to
45//! pre-resolve to catch it, and pre-resolving is what DNS-rebinding
46//! exploits.
47//! - It does not parse the path. A URL whose path is
48//! `/..%2F..%2Fetc%2Fpasswd` is an *application*-level problem on the
49//! remote end, not an SSRF one — the server POSTs the body, the
50//! receiver decides what to do with it.
51
52use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
53
54use url::{Host, Url};
55
56use crate::application::a2a::{FilterDenial, UrlFilter};
57use crate::domain::a2a::A2aVersion;
58
59/// The default SSRF denylist.
60///
61/// Constructed once and shared; the filter is stateless and `Sync`.
62#[derive(Debug, Clone, Copy, Default)]
63pub struct SsrfDenylist;
64
65impl SsrfDenylist {
66 /// The shared filter.
67 pub const FILTER: Self = Self;
68
69 /// Tests whether an IP is in a denied range.
70 ///
71 /// Public so a deployment-specific override can reuse the table — the
72 /// ranges are the OWASP-cited set, not a policy choice.
73 #[must_use]
74 pub fn is_denied_ip(ip: IpAddr) -> bool {
75 match ip {
76 IpAddr::V4(v4) => Self::is_denied_v4(v4),
77 IpAddr::V6(v6) => Self::is_denied_v6(v6),
78 }
79 }
80
81 fn is_denied_v4(v4: Ipv4Addr) -> bool {
82 let [a, b, c, _d] = v4.octets();
83
84 // 0.0.0.0/8 — "this network", the unspecified address, etc.
85 a == 0
86 // 127.0.0.0/8 — loopback.
87 || a == 127
88 // 10.0.0.0/8 — RFC 1918 private.
89 || a == 10
90 // 172.16.0.0/12 — RFC 1918 private.
91 || (a == 172 && (16..=31).contains(&b))
92 // 192.168.0.0/16 — RFC 1918 private.
93 || (a == 192 && b == 168)
94 // 169.254.0.0/16 — link-local, including 169.254.169.254 (cloud metadata).
95 || (a == 169 && b == 254)
96 // 192.0.2.0/24 — TEST-NET-1 (RFC 5737).
97 || (a == 192 && b == 0 && c == 2)
98 // 198.51.100.0/24 — TEST-NET-2 (RFC 5737).
99 || (a == 198 && b == 51 && c == 100)
100 // 203.0.113.0/24 — TEST-NET-3 (RFC 5737).
101 || (a == 203 && b == 0 && c == 113)
102 // 255.255.255.255/32 — limited broadcast.
103 || v4 == Ipv4Addr::BROADCAST
104 // 100.64.0.0/10 — RFC 6598 shared address space (carrier-grade NAT).
105 || (a == 100 && (64..=127).contains(&b))
106 }
107
108 fn is_denied_v6(v6: Ipv6Addr) -> bool {
109 // ::/128 — unspecified.
110 v6 == Ipv6Addr::UNSPECIFIED
111 // ::1/128 — loopback.
112 || v6 == Ipv6Addr::LOCALHOST
113 // fe80::/10 — link-local.
114 || (v6.segments()[0] & 0xffc0) == 0xfe80
115 // fc00::/7 — unique local address (RFC 4193, the IPv6 RFC 1918).
116 || (v6.segments()[0] & 0xfe00) == 0xfc00
117 // 2001:db8::/32 — documentation (RFC 3849).
118 || v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8
119 }
120
121 /// The static check, factored out so `check_at_config` and
122 /// `check_at_delivery` (for the URL portion) share the same logic.
123 fn check_url(url: &Url, version: A2aVersion) -> Result<(), FilterDenial> {
124 // Schemes first — file:// and gopher:// are SSRF primitives
125 // independent of what the host resolves to.
126 match url.scheme() {
127 "http" | "https" => {}
128 other => {
129 return Err(FilterDenial::UnsupportedScheme {
130 scheme: other.to_owned(),
131 });
132 }
133 }
134
135 // Userinfo is a known SSRF primitive in URL parsers that
136 // disagree about what `http://user:pass@host/path` means. The
137 // server is not honouring any credential the URL claims to carry.
138 if !url.username().is_empty() || url.password().is_some() {
139 return Err(FilterDenial::UserinfoNotAllowed);
140 }
141
142 let host = url.host().ok_or(FilterDenial::MissingHost)?;
143 match host {
144 Host::Domain(_) => {
145 // Hostname is deferred to the delivery-time check; the
146 // static check cannot evaluate a name it has not
147 // resolved. See `check_at_delivery`.
148 }
149 Host::Ipv4(v4) => {
150 if Self::is_denied_v4(v4) {
151 return Err(FilterDenial::DeniedIpLiteral {
152 literal: v4.to_string(),
153 });
154 }
155 }
156 Host::Ipv6(v6) => {
157 if Self::is_denied_v6(v6) {
158 return Err(FilterDenial::DeniedIpLiteral {
159 literal: format!("[{v6}]"),
160 });
161 }
162 }
163 }
164
165 // The path the URL names. A push-notification URL aimed at the
166 // server's own well-known path is a loopback primitive; refuse
167 // it here so the receiver is not its own webhook.
168 let path = url.path();
169 if path.starts_with("/.well-known/") {
170 return Err(FilterDenial::ReservedNamespace {
171 path: path.to_owned(),
172 });
173 }
174
175 // The version's reserved path is the same idea, version-tagged.
176 // A2A v1.0 uses `/.well-known/agent-card.json`; the path check
177 // above already catches it, but a future revision that picks a
178 // different namespace would not, and `version` is here so this
179 // branch can grow without changing the public signature.
180 let _ = version;
181
182 Ok(())
183 }
184}
185
186impl UrlFilter for SsrfDenylist {
187 fn check_at_config(&self, url: &Url, version: A2aVersion) -> Result<(), FilterDenial> {
188 Self::check_url(url, version)
189 }
190
191 fn check_at_delivery(
192 &self,
193 url: &Url,
194 resolved: IpAddr,
195 version: A2aVersion,
196 ) -> Result<(), FilterDenial> {
197 // The URL itself is re-checked, in case a deployment changed
198 // its policy between config and delivery, or the URL was
199 // tampered with in the store.
200 Self::check_url(url, version)?;
201
202 // And the resolved IP — this is the DNS-rebinding defense.
203 if Self::is_denied_ip(resolved) {
204 return Err(FilterDenial::DeniedResolvedIp { ip: resolved });
205 }
206
207 // An IPv4-mapped IPv6 address (::ffff:127.0.0.1) is the resolved
208 // IP for a hostname that returns a denied IPv4 over an IPv6
209 // socket. Catch the mapping explicitly so the message names the
210 // IPv4 the caller recognises, not the IPv6 wrapper.
211 if let IpAddr::V6(v6) = resolved
212 && let Some(v4) = v6.to_ipv4_mapped()
213 && Self::is_denied_v4(v4)
214 {
215 return Err(FilterDenial::DeniedResolvedIp { ip: IpAddr::V4(v4) });
216 }
217
218 Ok(())
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 #![expect(
225 clippy::expect_used,
226 clippy::panic,
227 reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
228 )]
229
230 use std::net::IpAddr;
231 use std::str::FromStr;
232
233 use url::Url;
234
235 use super::SsrfDenylist;
236 use crate::application::a2a::{FilterDenial, UrlFilter};
237 use crate::domain::a2a::A2aVersion;
238
239 const V1_0: A2aVersion = A2aVersion::V1_0;
240
241 fn url(s: &str) -> Url {
242 Url::from_str(s).expect("test fixture must be a valid URL")
243 }
244
245 fn ip(s: &str) -> IpAddr {
246 IpAddr::from_str(s).expect("test fixture must be a valid IP")
247 }
248
249 // --- `is_denied_v4` is the table the OWASP guidance names ----------
250
251 #[test]
252 fn is_denied_v4_covers_every_owasp_cited_range() {
253 // The cases the T24 audit called out by name.
254 let denied = [
255 "127.0.0.1",
256 "127.255.255.254",
257 "10.0.0.1",
258 "10.255.255.255",
259 "172.16.0.1",
260 "172.31.255.255",
261 "192.168.0.1",
262 "192.168.255.255",
263 "169.254.0.1",
264 "169.254.169.254", // cloud metadata endpoint
265 "0.0.0.0",
266 "255.255.255.255",
267 ];
268 for s in denied {
269 let addr = ip(s);
270 let IpAddr::V4(v4) = addr else {
271 panic!("{s} is not IPv4");
272 };
273 assert!(
274 SsrfDenylist::is_denied_v4(v4),
275 "{s} should be denied per the OWASP API7 list"
276 );
277 }
278 }
279
280 #[test]
281 fn is_denied_v4_allows_public_ranges() {
282 let allowed = [
283 "1.1.1.1", // Cloudflare DNS
284 "8.8.8.8", // Google DNS
285 "9.9.9.9", // Quad9
286 "93.184.216.34", // example.com
287 "172.32.0.1", // first address outside 172.16.0.0/12
288 "172.15.0.1", // last address outside
289 "169.255.0.1", // first address outside 169.254.0.0/16
290 "169.253.0.1", // last address outside
291 "11.0.0.1", // just outside 10.0.0.0/8
292 ];
293 for s in allowed {
294 let IpAddr::V4(v4) = ip(s) else {
295 panic!("{s} is not IPv4");
296 };
297 assert!(
298 !SsrfDenylist::is_denied_v4(v4),
299 "{s} should be allowed (public address)"
300 );
301 }
302 }
303
304 #[test]
305 fn is_denied_v6_covers_loopback_and_ula() {
306 let denied = ["::1", "::", "fe80::1", "fc00::1", "fd00::1", "2001:db8::1"];
307 for s in denied {
308 let IpAddr::V6(v6) = ip(s) else {
309 panic!("{s} is not IPv6");
310 };
311 assert!(
312 SsrfDenylist::is_denied_v6(v6),
313 "{s} should be denied per the IPv6 denylist"
314 );
315 }
316 }
317
318 #[test]
319 fn is_denied_v6_allows_public_ranges() {
320 let allowed = [
321 "2001:4860:4860::8888", // Google DNS
322 "2606:4700:4700::1111", // Cloudflare DNS
323 ];
324 for s in allowed {
325 let IpAddr::V6(v6) = ip(s) else {
326 panic!("{s} is not IPv6");
327 };
328 assert!(
329 !SsrfDenylist::is_denied_v6(v6),
330 "{s} should be allowed (public IPv6)"
331 );
332 }
333 }
334
335 // --- `check_at_config` accepts public URLs and refuses denylist ---
336
337 #[test]
338 fn check_at_config_accepts_a_public_https_url() {
339 SsrfDenylist::FILTER
340 .check_at_config(&url("https://example.com/hook"), V1_0)
341 .expect("the call must succeed");
342 }
343
344 #[test]
345 fn check_at_config_refuses_loopback() {
346 let err = SsrfDenylist::FILTER
347 .check_at_config(&url("http://127.0.0.1/hook"), V1_0)
348 .expect_err("loopback must be denied");
349 assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
350 }
351
352 #[test]
353 fn check_at_config_refuses_cloud_metadata() {
354 let err = SsrfDenylist::FILTER
355 .check_at_config(&url("http://169.254.169.254/latest/meta-data/"), V1_0)
356 .expect_err("cloud metadata endpoint must be denied");
357 assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
358 }
359
360 #[test]
361 fn check_at_config_refuses_rfc_1918() {
362 for s in [
363 "http://10.0.0.1/",
364 "http://172.16.0.1/",
365 "http://192.168.1.1/",
366 ] {
367 let err = SsrfDenylist::FILTER
368 .check_at_config(&url(s), V1_0)
369 .expect_err("RFC 1918 must be denied");
370 assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
371 }
372 }
373
374 #[test]
375 fn check_at_config_refuses_ipv6_loopback() {
376 let err = SsrfDenylist::FILTER
377 .check_at_config(&url("http://[::1]/hook"), V1_0)
378 .expect_err("IPv6 loopback must be denied");
379 assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
380 }
381
382 #[test]
383 fn check_at_config_refuses_non_http_schemes() {
384 for scheme in ["file", "gopher", "ftp", "ldap", "dict"] {
385 let u = url(&format!("{scheme}://example.com/x"));
386 let err = SsrfDenylist::FILTER
387 .check_at_config(&u, V1_0)
388 .expect_err(&format!("{scheme} must be denied"));
389 let matched = matches!(err, FilterDenial::UnsupportedScheme { .. });
390 assert!(matched, "{scheme} denial was {err:?}");
391 }
392 }
393
394 #[test]
395 fn check_at_config_refuses_userinfo() {
396 let err = SsrfDenylist::FILTER
397 .check_at_config(&url("https://attacker:pwn@example.com/"), V1_0)
398 .expect_err("userinfo must be denied");
399 assert_eq!(err, FilterDenial::UserinfoNotAllowed);
400 }
401
402 #[test]
403 fn check_at_config_refuses_well_known_path() {
404 // A push notification aimed at the agent's own well-known
405 // path is a loopback primitive; the static check refuses it.
406 let err = SsrfDenylist::FILTER
407 .check_at_config(
408 &url("https://example.com/.well-known/agent-card.json"),
409 V1_0,
410 )
411 .expect_err("well-known path must be denied");
412 assert_matches::assert_matches!(err, FilterDenial::ReservedNamespace { .. });
413 }
414
415 // --- `check_at_delivery` is the DNS-rebinding defense -------------
416
417 #[test]
418 fn check_at_delivery_refuses_a_dns_rebind_to_loopback() {
419 // The URL string was acceptable at config time (hostname, no
420 // literal). At delivery time the resolver returned 127.0.0.1.
421 let err = SsrfDenylist::FILTER
422 .check_at_delivery(
423 &url("https://benign.example.com/hook"),
424 ip("127.0.0.1"),
425 V1_0,
426 )
427 .expect_err("DNS-rebind to loopback must be denied at delivery");
428 assert_eq!(
429 err,
430 FilterDenial::DeniedResolvedIp {
431 ip: ip("127.0.0.1"),
432 }
433 );
434 }
435
436 #[test]
437 fn check_at_delivery_refuses_a_dns_rebind_to_metadata() {
438 let err = SsrfDenylist::FILTER
439 .check_at_delivery(
440 &url("https://benign.example.com/hook"),
441 ip("169.254.169.254"),
442 V1_0,
443 )
444 .expect_err("DNS-rebind to cloud metadata must be denied at delivery");
445 assert_eq!(
446 err,
447 FilterDenial::DeniedResolvedIp {
448 ip: ip("169.254.169.254"),
449 }
450 );
451 }
452
453 #[test]
454 fn check_at_delivery_unwraps_ipv4_mapped_ipv6() {
455 // Some resolvers return 127.0.0.1 over an IPv6 socket as
456 // ::ffff:127.0.0.1. The denylist should unwrap the mapping and
457 // name the IPv4 the operator recognises, not the wrapper.
458 let err = SsrfDenylist::FILTER
459 .check_at_delivery(
460 &url("https://benign.example.com/hook"),
461 ip("::ffff:127.0.0.1"),
462 V1_0,
463 )
464 .expect_err("IPv4-mapped loopback must be denied at delivery");
465 assert_eq!(
466 err,
467 FilterDenial::DeniedResolvedIp {
468 ip: ip("127.0.0.1"),
469 },
470 "the denial should name the IPv4 the operator recognises, not the wrapper"
471 );
472 }
473
474 #[test]
475 fn check_at_delivery_accepts_a_public_resolved_ip() {
476 SsrfDenylist::FILTER
477 .check_at_delivery(
478 &url("https://benign.example.com/hook"),
479 ip("93.184.216.34"),
480 V1_0,
481 )
482 .expect("the call must succeed");
483 }
484
485 #[test]
486 fn check_at_delivery_rechecks_the_url() {
487 // If the stored URL has been tampered with between config and
488 // delivery (a write-side bug, a misbehaving registry, an
489 // attacker who pwned the persistence layer), the delivery
490 // check should still catch the IP literal.
491 let err = SsrfDenylist::FILTER
492 .check_at_delivery(&url("http://10.0.0.1/hook"), ip("93.184.216.34"), V1_0)
493 .expect_err("a tampered URL with a denied IP literal must still be denied");
494 assert_matches::assert_matches!(err, FilterDenial::DeniedIpLiteral { .. });
495 }
496}