Skip to main content

a2a_server/push/
sender.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// Copyright A2A Contributors (https://github.com/a2aproject)
3// SPDX-License-Identifier: Apache-2.0
4use a2a::*;
5use reqwest::dns::{Addrs, Name, Resolve, Resolving};
6use std::net::SocketAddr;
7use std::sync::Arc;
8use std::time::Duration;
9
10/// HTTP push notification sender.
11///
12/// Sends A2A streaming events to a push notification endpoint over HTTP POST.
13pub struct HttpPushSender {
14    client: reqwest::Client,
15    fail_on_error: bool,
16    validate_urls: bool,
17}
18
19/// Configuration for [`HttpPushSender`].
20pub struct HttpPushSenderConfig {
21    /// HTTP request timeout (default: 30s).
22    pub timeout: Duration,
23    /// If true, push sending errors abort execution.
24    pub fail_on_error: bool,
25    /// If true (default), guard push URLs against SSRF. A string pre-check
26    /// (scheme must be http/https; host must not be a loopback, private,
27    /// link-local, multicast, unspecified, or cloud-metadata literal) fails
28    /// before any network activity. A connect-time resolver then drops any
29    /// resolved address in those ranges before dialing, so a hostname cannot
30    /// reach a blocked address through DNS (split-horizon, attacker-controlled
31    /// records, or rebinding). Redirects are re-screened per hop with the same
32    /// string check, and the sender uses no system proxy, since either path
33    /// would otherwise reach an address the guard never saw. Disable only when
34    /// pushing to local webhooks in trusted test environments; that turns off
35    /// the whole guard.
36    pub validate_urls: bool,
37}
38
39impl Default for HttpPushSenderConfig {
40    fn default() -> Self {
41        HttpPushSenderConfig {
42            timeout: Duration::from_secs(30),
43            fail_on_error: false,
44            validate_urls: true,
45        }
46    }
47}
48
49impl HttpPushSender {
50    pub fn new(config: Option<HttpPushSenderConfig>) -> Self {
51        let config = config.unwrap_or_default();
52        // When URL validation is on, the guard needs three things reqwest does
53        // not do by default, because each one is a way a request reaches an
54        // address the string check never saw:
55        //   - a resolver that screens every resolved address at connect time
56        //     (see SsrfGuardedResolver), since the string check cannot see
57        //     where a hostname actually resolves;
58        //   - a redirect policy that re-screens each hop with validate_push_url,
59        //     since a redirect to a blocked literal IP skips DNS entirely and so
60        //     never reaches the resolver, and the string check only ran on the
61        //     original URL;
62        //   - no system proxy, since a proxy would resolve and reach the target
63        //     itself, leaving only the proxy address screened.
64        // When validation is off (the single documented opt-out for local test
65        // webhooks), keep reqwest's defaults so loopback targets still work.
66        let mut builder = reqwest::Client::builder().timeout(config.timeout);
67        if config.validate_urls {
68            builder = builder
69                .dns_resolver(Arc::new(SsrfGuardedResolver))
70                .no_proxy()
71                .redirect(ssrf_guarded_redirect_policy());
72        }
73        let client = builder.build().expect("failed to create HTTP client");
74        HttpPushSender {
75            client,
76            fail_on_error: config.fail_on_error,
77            validate_urls: config.validate_urls,
78        }
79    }
80
81    /// Send an event to the push notification endpoint.
82    pub async fn send_push(
83        &self,
84        config: &TaskPushNotificationConfig,
85        event: StreamResponse,
86    ) -> Result<(), A2AError> {
87        if self.validate_urls {
88            validate_push_url(&config.url)?;
89        }
90
91        // Reject credentials containing CR/LF so they cannot inject
92        // additional headers into the outgoing request (BUG-34).
93        if let Some(ref token) = config.token {
94            if token.contains('\r') || token.contains('\n') {
95                return Err(A2AError::invalid_params(
96                    "push notification token must not contain CR/LF",
97                ));
98            }
99        }
100        if let Some(ref auth) = config.authentication {
101            if let Some(ref creds) = auth.credentials {
102                if creds.contains('\r') || creds.contains('\n') {
103                    return Err(A2AError::invalid_params(
104                        "push credentials must not contain CR/LF",
105                    ));
106                }
107            }
108        }
109
110        let body = match serde_json::to_vec(&event) {
111            Ok(b) => b,
112            Err(e) => return self.handle_error(format!("failed to serialize event: {e}")),
113        };
114
115        let mut request = self
116            .client
117            .post(&config.url)
118            .header("Content-Type", "application/json")
119            .body(body);
120
121        if let Some(ref token) = config.token {
122            request = request.header("A2A-Notification-Token", token);
123        }
124
125        if let Some(ref auth) = config.authentication {
126            if let Some(ref creds) = auth.credentials {
127                match auth.scheme.to_lowercase().as_str() {
128                    "bearer" => {
129                        request = request.header("Authorization", format!("Bearer {creds}"));
130                    }
131                    "basic" => {
132                        request = request.header("Authorization", format!("Basic {creds}"));
133                    }
134                    _ => {}
135                }
136            }
137        }
138
139        match request.send().await {
140            Ok(resp) => {
141                if !resp.status().is_success() {
142                    return self
143                        .handle_error(format!("push endpoint returned status: {}", resp.status()));
144                }
145                Ok(())
146            }
147            Err(e) => self.handle_error(format!("failed to send push notification: {e}")),
148        }
149    }
150
151    fn handle_error(&self, msg: String) -> Result<(), A2AError> {
152        if self.fail_on_error {
153            Err(crate::sanitized_internal_error(&msg))
154        } else {
155            tracing::error!("{}", msg);
156            Ok(())
157        }
158    }
159}
160
161/// Validate a push notification URL against SSRF targets (BUG-54).
162///
163/// The URL scheme must be http/https and the host must not resolve to a
164/// loopback, private, link-local, multicast, or unspecified address, nor to a
165/// well-known cloud metadata endpoint.
166pub(crate) fn validate_push_url(url: &str) -> Result<(), A2AError> {
167    let parsed = reqwest::Url::parse(url)
168        .map_err(|_| A2AError::invalid_params("invalid push notification URL"))?;
169
170    if parsed.scheme() != "http" && parsed.scheme() != "https" {
171        return Err(A2AError::invalid_params("push URL must be http or https"));
172    }
173
174    if let Some(host) = parsed.host_str() {
175        // A trailing dot is an equivalent fully-qualified form: "localhost."
176        // resolves exactly as "localhost" does. Normalise it away, or every
177        // hostname below is bypassed by appending one character. IP literals
178        // are unaffected either way -- Url::parse already strips the dot for
179        // those, which is why "127.0.0.1." was blocked and "localhost." was
180        // not.
181        let host = host.strip_suffix('.').unwrap_or(host);
182        // Block well-known loopback, metadata, and unspecified hosts.
183        let blocked = [
184            "127.0.0.1",
185            "localhost",
186            "::1",
187            "[::1]",
188            "169.254.169.254",
189            "metadata.google.internal",
190            "metadata.azure.com",
191            "metadata.goog",
192            "0.0.0.0",
193        ];
194        if blocked.contains(&host) {
195            return Err(A2AError::invalid_params("push URL targets blocked host"));
196        }
197        // Block IP literals in restricted ranges (loopback, RFC 1918
198        // private, link-local, ULA, unspecified, multicast). For IPv6 URLs
199        // the host serializes WITH brackets ("[fc00::1]"), which never
200        // parses as an IpAddr — strip them first or every range check
201        // below is silently skipped.
202        let host_unbracketed = host
203            .strip_prefix('[')
204            .and_then(|h| h.strip_suffix(']'))
205            .unwrap_or(host);
206        if let Ok(ip) = host_unbracketed.parse::<std::net::IpAddr>() {
207            if is_blocked_ip(ip) {
208                return Err(A2AError::invalid_params(
209                    "push URL targets private/loopback/link-local address",
210                ));
211            }
212        }
213    }
214
215    Ok(())
216}
217
218/// Whether `ip` is loopback, RFC 1918/ULA private, link-local, unspecified,
219/// or multicast -- the ranges a push URL must not target.
220///
221/// A standalone predicate rather than inline in `validate_push_url`, per
222/// #224's own scope: the connect-time guard it asks for needs to check the
223/// *resolved* address against these same ranges, and must reuse this rather
224/// than duplicate it. It is also what makes target 4 of #238's fuzz plan
225/// possible: fuzzing "does `validate_push_url` correctly extract an IP from
226/// an arbitrary URL and apply this predicate" needs the predicate itself to
227/// be callable as independent ground truth, not re-derived by the fuzz
228/// target and risking drifting out of sync with the real one.
229pub(crate) fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
230    match ip {
231        std::net::IpAddr::V4(v4) => {
232            v4.is_loopback()
233                || v4.is_private()
234                || v4.is_link_local()
235                || v4.is_unspecified()
236                || v4.is_multicast()
237        }
238        std::net::IpAddr::V6(v6) => {
239            // fc00::/7 unique local addresses (IPv6 "private").
240            let is_ula = (v6.segments()[0] & 0xfe00) == 0xfc00;
241            // IPv4-mapped IPv6 literals (::ffff:a.b.c.d) must be checked
242            // against the IPv4 restrictions too, or they bypass the
243            // loopback/private/link-local checks above.
244            let mapped_v4_blocked = v6.to_ipv4_mapped().is_some_and(|v4| {
245                v4.is_loopback()
246                    || v4.is_private()
247                    || v4.is_link_local()
248                    || v4.is_unspecified()
249                    || v4.is_multicast()
250            });
251            v6.is_loopback()
252                || is_ula
253                || mapped_v4_blocked
254                || v6.is_unicast_link_local()
255                || v6.is_unspecified()
256                || v6.is_multicast()
257        }
258    }
259}
260
261/// Retain only the socket addresses safe to dial, dropping any whose IP
262/// [`is_blocked_ip`] rejects. Split out from [`SsrfGuardedResolver`] so the
263/// connect-time filter is unit-testable without performing real DNS.
264fn connectable_addrs(addrs: impl Iterator<Item = SocketAddr>) -> Vec<SocketAddr> {
265    addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect()
266}
267
268/// A `reqwest` resolver that screens resolved addresses against
269/// [`is_blocked_ip`] before the socket is dialed (#224).
270///
271/// `validate_push_url` screens the URL *string*, but a hostname can still
272/// resolve to a loopback, private, or link-local address through split-horizon
273/// DNS, an attacker-controlled A record, or DNS rebinding. This resolver is the
274/// connect-time half of the guard: reqwest dials exactly the addresses returned
275/// here, so filtering them is not subject to a resolve-then-reconnect TOCTOU.
276/// Installed only when `validate_urls` is set.
277///
278/// This resolver only sees addresses reqwest resolves from a hostname. Two
279/// paths reach an address without hitting it, so [`HttpPushSender::new`] closes
280/// them alongside installing this resolver: a redirect to a blocked literal IP
281/// skips DNS entirely (handled by re-screening every redirect hop with
282/// `validate_push_url`), and a system proxy would resolve the target itself
283/// (handled by disabling the proxy). All three are gated on the same
284/// `validate_urls` flag.
285struct SsrfGuardedResolver;
286
287impl Resolve for SsrfGuardedResolver {
288    fn resolve(&self, name: Name) -> Resolving {
289        Box::pin(async move {
290            let host = name.as_str().to_owned();
291            // Port 0: reqwest replaces it with the request's port before
292            // dialing; only the resolved IP matters for the range check.
293            let resolved = tokio::net::lookup_host((host.as_str(), 0)).await?;
294            let allowed = connectable_addrs(resolved);
295            if allowed.is_empty() {
296                return Err(Box::<dyn std::error::Error + Send + Sync>::from(
297                    "push URL host resolves only to blocked (loopback, private, \
298                     link-local, or metadata) addresses",
299                ));
300            }
301            Ok(Box::new(allowed.into_iter()) as Addrs)
302        })
303    }
304}
305
306/// The redirect policy installed when `validate_urls` is set: re-screens
307/// each hop with [`validate_push_url`], since a redirect to a blocked
308/// literal IP skips DNS entirely and never reaches [`SsrfGuardedResolver`].
309///
310/// Split out from [`HttpPushSender::new`] so a test can attach it to a
311/// client on its own, without the resolver -- `reqwest::redirect::Attempt`
312/// has no public constructor, so driving a real HTTP redirect through this
313/// policy is the only way to exercise it.
314fn ssrf_guarded_redirect_policy() -> reqwest::redirect::Policy {
315    reqwest::redirect::Policy::custom(|attempt| {
316        if attempt.previous().len() >= 10 {
317            return attempt.stop();
318        }
319        if validate_push_url(attempt.url().as_str()).is_err() {
320            attempt.error("push URL redirect target is blocked")
321        } else {
322            attempt.follow()
323        }
324    })
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    use crate::test_util::install_crypto_provider;
332
333    #[test]
334    fn test_default_config() {
335        let config = HttpPushSenderConfig::default();
336        assert_eq!(config.timeout, Duration::from_secs(30));
337        assert!(!config.fail_on_error);
338        assert!(config.validate_urls);
339    }
340
341    #[test]
342    fn test_sender_new_default() {
343        install_crypto_provider();
344        let sender = HttpPushSender::new(None);
345        assert!(!sender.fail_on_error);
346        assert!(sender.validate_urls);
347    }
348
349    #[test]
350    fn test_sender_new_custom() {
351        install_crypto_provider();
352        let config = HttpPushSenderConfig {
353            timeout: Duration::from_secs(10),
354            fail_on_error: true,
355            validate_urls: false,
356        };
357        let sender = HttpPushSender::new(Some(config));
358        assert!(sender.fail_on_error);
359        assert!(!sender.validate_urls);
360    }
361
362    #[test]
363    fn test_handle_error_fail_on_error() {
364        install_crypto_provider();
365        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
366            timeout: Duration::from_secs(5),
367            fail_on_error: true,
368            validate_urls: true,
369        }));
370        let result = sender.handle_error("test error".to_string());
371        assert!(result.is_err());
372    }
373
374    #[test]
375    fn test_handle_error_ignore() {
376        install_crypto_provider();
377        let sender = HttpPushSender::new(None);
378        let result = sender.handle_error("test error".to_string());
379        assert!(result.is_ok());
380    }
381
382    fn sample_status_update() -> StreamResponse {
383        use a2a::event::*;
384        StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
385            task_id: "t1".into(),
386            context_id: "c1".into(),
387            status: TaskStatus {
388                state: TaskState::Working,
389                message: None,
390                timestamp: None,
391            },
392            metadata: None,
393        })
394    }
395
396    fn sample_config(url: &str) -> TaskPushNotificationConfig {
397        TaskPushNotificationConfig {
398            task_id: String::new(),
399            url: url.to_string(),
400            id: None,
401            token: None,
402            authentication: None,
403            tenant: None,
404        }
405    }
406
407    #[tokio::test]
408    async fn test_send_push_connection_refused_no_fail() {
409        install_crypto_provider();
410        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
411            validate_urls: false,
412            ..Default::default()
413        }));
414        let config = TaskPushNotificationConfig {
415            task_id: String::new(),
416            url: "http://127.0.0.1:1/callback".to_string(),
417            id: None,
418            token: Some("tok".to_string()),
419            authentication: Some(AuthenticationInfo {
420                scheme: "bearer".to_string(),
421                credentials: Some("secret".to_string()),
422            }),
423            tenant: None,
424        };
425        let result = sender.send_push(&config, sample_status_update()).await;
426        // Should be Ok because fail_on_error is false
427        assert!(result.is_ok());
428    }
429
430    #[tokio::test]
431    async fn test_send_push_connection_refused_fail() {
432        install_crypto_provider();
433        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
434            timeout: std::time::Duration::from_millis(100),
435            fail_on_error: true,
436            validate_urls: false,
437        }));
438        let config = sample_config("http://127.0.0.1:1/callback");
439        let result = sender.send_push(&config, sample_status_update()).await;
440        assert!(result.is_err());
441    }
442
443    #[tokio::test]
444    async fn test_send_push_with_basic_auth() {
445        install_crypto_provider();
446        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
447            validate_urls: false,
448            ..Default::default()
449        }));
450        let config = TaskPushNotificationConfig {
451            task_id: String::new(),
452            url: "http://127.0.0.1:1/callback".to_string(),
453            id: None,
454            token: None,
455            authentication: Some(AuthenticationInfo {
456                scheme: "basic".to_string(),
457                credentials: Some("dXNlcjpwYXNz".to_string()),
458            }),
459            tenant: None,
460        };
461        let result = sender.send_push(&config, sample_status_update()).await;
462        // Will fail to connect but exercises basic auth header path
463        assert!(result.is_ok()); // fail_on_error=false
464    }
465
466    #[tokio::test]
467    async fn test_send_push_with_unknown_auth_scheme() {
468        install_crypto_provider();
469        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
470            validate_urls: false,
471            ..Default::default()
472        }));
473        let config = TaskPushNotificationConfig {
474            task_id: String::new(),
475            url: "http://127.0.0.1:1/callback".to_string(),
476            id: None,
477            token: None,
478            authentication: Some(AuthenticationInfo {
479                scheme: "custom".to_string(),
480                credentials: Some("cred".to_string()),
481            }),
482            tenant: None,
483        };
484        let result = sender.send_push(&config, sample_status_update()).await;
485        assert!(result.is_ok());
486    }
487
488    #[test]
489    fn test_validate_push_url_rejects_non_http_schemes() {
490        for url in [
491            "file:///etc/passwd",
492            "ftp://example.com/hook",
493            "gopher://example.com/",
494        ] {
495            let err = validate_push_url(url).unwrap_err();
496            assert_eq!(err.code, error_code::INVALID_PARAMS);
497        }
498    }
499
500    #[test]
501    fn test_validate_push_url_rejects_loopback() {
502        for url in [
503            "http://127.0.0.1:1/callback",
504            "http://localhost/callback",
505            "http://[::1]/callback",
506            "http://0.0.0.0/callback",
507        ] {
508            assert!(
509                validate_push_url(url).is_err(),
510                "expected {url} to be rejected"
511            );
512        }
513    }
514
515    #[test]
516    fn test_validate_push_url_rejects_private_and_link_local() {
517        for url in [
518            "http://10.0.0.1/callback",
519            "http://192.168.1.1/callback",
520            "http://172.16.0.1/callback",
521            "http://169.254.169.254/latest/meta-data/",
522            "http://169.254.1.1/callback",
523            "http://metadata.google.internal/callback",
524        ] {
525            assert!(
526                validate_push_url(url).is_err(),
527                "expected {url} to be rejected"
528            );
529        }
530    }
531
532    #[test]
533    fn test_validate_push_url_rejects_trailing_dot_hosts() {
534        // "localhost." is the fully-qualified spelling of "localhost" and
535        // resolves identically, so it must not slip past the host blocklist.
536        for url in [
537            "http://localhost./hook",
538            "http://LocalHost./hook",
539            "https://localhost./hook",
540            "http://metadata.google.internal./hook",
541            "http://metadata.azure.com./hook",
542        ] {
543            assert!(
544                validate_push_url(url).is_err(),
545                "{url} must be blocked, but was allowed"
546            );
547        }
548    }
549
550    #[test]
551    fn test_validate_push_url_rejects_alternate_ipv4_spellings() {
552        // These are blocked only because Url::parse normalises them to
553        // 127.0.0.1 before the range check sees them. Pin that, so the
554        // normalisation cannot be regressed away unnoticed.
555        for url in [
556            "http://2130706433/hook",         // decimal
557            "http://0x7f.0.0.1/hook",         // hex octet
558            "http://127.1/hook",              // short form
559            "http://127.0.0.1./hook",         // trailing dot on a literal
560            "http://[::ffff:127.0.0.1]/hook", // IPv4-mapped IPv6
561        ] {
562            assert!(
563                validate_push_url(url).is_err(),
564                "{url} must be blocked, but was allowed"
565            );
566        }
567    }
568
569    #[test]
570    fn test_is_blocked_ip_checks_every_range_through_an_ipv4_mapped_address() {
571        // is_blocked_ip's IPv4-mapped-IPv6 branch applies every IPv4
572        // predicate to the unwrapped address, not just is_loopback -- the
573        // existing ::ffff:127.0.0.1 test case above only exercises that
574        // first one, since a loopback address short-circuits the rest.
575        // Each of these is chosen to be false on every earlier predicate,
576        // so the run reaches its own line rather than returning early.
577        for (ip, why) in [
578            ("::ffff:169.254.1.1", "link-local"),
579            ("::ffff:0.0.0.0", "unspecified"),
580            ("::ffff:224.0.0.1", "multicast"),
581        ] {
582            let addr: std::net::IpAddr = ip.parse().unwrap();
583            assert!(is_blocked_ip(addr), "{ip} ({why}) should be blocked");
584        }
585
586        // And the mapped address is not blocked when the underlying IPv4
587        // address is none of these -- confirms the closure's result is
588        // actually load-bearing, not a predicate that always returns true.
589        let public: std::net::IpAddr = "::ffff:93.184.216.34".parse().unwrap();
590        assert!(
591            !is_blocked_ip(public),
592            "a public mapped address was blocked"
593        );
594    }
595
596    #[test]
597    fn test_validate_push_url_rejects_blocked_hosts_regardless_of_case() {
598        // Url::parse lowercases the host per the URL Standard before the
599        // blocklist ever sees it, so this holds independent of the
600        // trailing-dot case above -- pinned on its own rather than only as
601        // a side effect of that test.
602        for url in [
603            "http://LOCALHOST/hook",
604            "http://METADATA.GOOGLE.INTERNAL/hook",
605            "http://Metadata.Azure.Com/hook",
606        ] {
607            assert!(
608                validate_push_url(url).is_err(),
609                "{url} must be blocked, but was allowed"
610            );
611        }
612    }
613
614    #[test]
615    fn test_validate_push_url_accepts_public_urls() {
616        for url in [
617            "http://example.com/callback",
618            "https://example.com/callback",
619            "https://hooks.example.com/abc?x=1",
620        ] {
621            assert!(
622                validate_push_url(url).is_ok(),
623                "expected {url} to be accepted"
624            );
625        }
626    }
627
628    #[tokio::test]
629    async fn test_send_push_rejects_blocked_url_even_without_fail_on_error() {
630        install_crypto_provider();
631        let sender = HttpPushSender::new(None); // validate_urls = true
632        let config = sample_config("http://127.0.0.1:1/callback");
633        let result = sender.send_push(&config, sample_status_update()).await;
634        // Validation errors are always returned, regardless of fail_on_error.
635        assert!(result.is_err());
636        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
637    }
638
639    #[tokio::test]
640    async fn test_send_push_rejects_crlf_credentials() {
641        install_crypto_provider();
642        let sender = HttpPushSender::new(None);
643        let config = TaskPushNotificationConfig {
644            task_id: String::new(),
645            url: "https://example.com/callback".to_string(),
646            id: None,
647            token: None,
648            authentication: Some(AuthenticationInfo {
649                scheme: "bearer".to_string(),
650                credentials: Some("secret\r\nX-Injected: 1".to_string()),
651            }),
652            tenant: None,
653        };
654        let result = sender.send_push(&config, sample_status_update()).await;
655        assert!(result.is_err());
656        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
657    }
658
659    #[tokio::test]
660    async fn test_send_push_rejects_crlf_token() {
661        install_crypto_provider();
662        let sender = HttpPushSender::new(None);
663        let config = TaskPushNotificationConfig {
664            task_id: String::new(),
665            url: "https://example.com/callback".to_string(),
666            id: None,
667            token: Some("tok\nX-Injected: 1".to_string()),
668            authentication: None,
669            tenant: None,
670        };
671        let result = sender.send_push(&config, sample_status_update()).await;
672        assert!(result.is_err());
673        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
674    }
675
676    #[test]
677    fn test_validate_push_url_rejects_invalid_url() {
678        assert!(validate_push_url("not a url").is_err());
679        assert!(validate_push_url("").is_err());
680        // Empty authority fails URL parsing outright (EmptyHost).
681        assert!(validate_push_url("http://").is_err());
682    }
683
684    #[test]
685    fn test_validate_push_url_rejects_ipv6_restricted_ranges() {
686        for url in [
687            "http://[fc00::1]/cb",
688            "http://[fd12:3456:789a::1]/cb",
689            "http://[fe80::1]/cb",
690            "http://[ff02::1]/cb",
691            "http://[::]/cb",
692        ] {
693            let result = validate_push_url(url);
694            assert!(result.is_err(), "expected {url} to be rejected");
695            assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
696        }
697    }
698
699    #[test]
700    fn test_validate_push_url_rejects_ipv4_mapped_ipv6() {
701        // ::ffff:a.b.c.d literals must inherit the IPv4 restrictions.
702        for url in [
703            "http://[::ffff:127.0.0.1]/cb",
704            "http://[::ffff:10.0.0.1]/cb",
705        ] {
706            let result = validate_push_url(url);
707            assert!(result.is_err(), "expected {url} to be rejected");
708            assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
709        }
710    }
711
712    #[test]
713    fn test_validate_push_url_rejects_ipv4_multicast() {
714        let result = validate_push_url("http://224.0.0.1/cb");
715        assert!(result.is_err());
716        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
717    }
718
719    #[test]
720    fn test_validate_push_url_accepts_global_ipv6() {
721        assert!(validate_push_url("http://[2606:4700::1111]/cb").is_ok());
722    }
723
724    #[test]
725    fn test_connectable_addrs_drops_blocked_keeps_public() {
726        // The connect-time filter (#224) must drop every restricted resolved
727        // address and keep the routable ones, so a hostname that resolves to a
728        // mix cannot reach the blocked members.
729        let addrs: Vec<SocketAddr> = vec![
730            "127.0.0.1:443".parse().unwrap(),         // loopback
731            "10.0.0.5:443".parse().unwrap(),          // RFC 1918
732            "169.254.169.254:80".parse().unwrap(),    // link-local metadata
733            "93.184.216.34:443".parse().unwrap(),     // public v4
734            "[fc00::1]:443".parse().unwrap(),         // IPv6 ULA
735            "[2606:4700::1111]:443".parse().unwrap(), // public v6
736        ];
737        let ips: Vec<std::net::IpAddr> = connectable_addrs(addrs.into_iter())
738            .iter()
739            .map(|a| a.ip())
740            .collect();
741        assert_eq!(
742            ips,
743            vec![
744                "93.184.216.34".parse::<std::net::IpAddr>().unwrap(),
745                "2606:4700::1111".parse::<std::net::IpAddr>().unwrap(),
746            ]
747        );
748    }
749
750    #[test]
751    fn test_connectable_addrs_all_blocked_is_empty() {
752        let addrs: Vec<SocketAddr> = vec![
753            "127.0.0.1:443".parse().unwrap(),
754            "192.168.1.1:443".parse().unwrap(),
755        ];
756        assert!(connectable_addrs(addrs.into_iter()).is_empty());
757    }
758
759    #[tokio::test]
760    async fn test_ssrf_resolver_rejects_host_resolving_to_loopback() {
761        // A bare hostname that resolves only to loopback must be refused at
762        // connect time, even though the resolver never sees an IP literal.
763        // "localhost" resolves to 127.0.0.1 / ::1 without leaving the machine,
764        // so this is deterministic.
765        let name: Name = "localhost".parse().expect("localhost is a valid dns name");
766        let result = SsrfGuardedResolver.resolve(name).await;
767        assert!(
768            result.is_err(),
769            "a host resolving only to loopback must be refused at connect time"
770        );
771    }
772
773    #[test]
774    fn test_redirect_hop_check_rejects_blocked_targets() {
775        // The custom redirect policy re-runs validate_push_url on each hop, so a
776        // redirect to a blocked literal IP (which skips DNS and never reaches
777        // SsrfGuardedResolver) is refused, while a public hop follows. This pins
778        // the per-hop check the policy relies on.
779        assert!(validate_push_url("http://127.0.0.1/").is_err());
780        assert!(validate_push_url("http://169.254.169.254/latest/meta-data/").is_err());
781        assert!(validate_push_url("http://[::1]/").is_err());
782        assert!(validate_push_url("https://example.com/next").is_ok());
783    }
784
785    /// Runs a local server that redirects `/` to `location`, so a real
786    /// `reqwest::redirect::Attempt` reaches [`ssrf_guarded_redirect_policy`]
787    /// -- the type has no public constructor, so this is the only way to
788    /// drive it without faking reqwest's internals.
789    async fn spawn_redirect_server(location: String) -> String {
790        let app = axum::Router::new().route(
791            "/",
792            axum::routing::get(move || {
793                let location = location.clone();
794                async move { axum::response::Redirect::temporary(&location) }
795            }),
796        );
797        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
798        let addr = listener.local_addr().unwrap();
799        tokio::spawn(async move {
800            axum::serve(listener, app).await.unwrap();
801        });
802        format!("http://{addr}/")
803    }
804
805    #[tokio::test]
806    async fn test_redirect_policy_blocks_a_redirect_to_a_disallowed_target() {
807        let server_url =
808            spawn_redirect_server("http://169.254.169.254/latest/meta-data/".into()).await;
809        let client = reqwest::Client::builder()
810            .redirect(ssrf_guarded_redirect_policy())
811            .build()
812            .unwrap();
813        let err = client
814            .get(&server_url)
815            .send()
816            .await
817            .expect_err("a redirect to a blocked target must fail");
818        assert!(err.is_redirect(), "expected a redirect-policy error: {err}");
819    }
820
821    #[tokio::test]
822    async fn test_redirect_policy_rejects_a_self_redirect_loop_immediately() {
823        // A server that redirects to itself is a loopback target, so the
824        // per-hop block check (not the separate 10-hop cap, which nothing
825        // here can safely reach without a real allowed target to bounce
826        // through) is what stops it -- on the very first hop.
827        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
828        let addr = listener.local_addr().unwrap();
829        let self_url = format!("http://{addr}/");
830        let app = axum::Router::new().route(
831            "/",
832            axum::routing::get(move || {
833                let self_url = self_url.clone();
834                async move { axum::response::Redirect::temporary(&self_url) }
835            }),
836        );
837        tokio::spawn(async move {
838            axum::serve(listener, app).await.unwrap();
839        });
840
841        let client = reqwest::Client::builder()
842            .redirect(ssrf_guarded_redirect_policy())
843            .build()
844            .unwrap();
845        let err = client
846            .get(format!("http://{addr}/"))
847            .send()
848            .await
849            .expect_err("a loopback self-redirect must be refused, not followed");
850        assert!(err.is_redirect(), "expected a redirect-policy error: {err}");
851    }
852}