Skip to main content

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}