Skip to main content

a2a_server/push/
sender.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use a2a::*;
4use std::time::Duration;
5
6/// HTTP push notification sender.
7///
8/// Sends A2A streaming events to a push notification endpoint over HTTP POST.
9pub struct HttpPushSender {
10    client: reqwest::Client,
11    fail_on_error: bool,
12    validate_urls: bool,
13}
14
15/// Configuration for [`HttpPushSender`].
16pub struct HttpPushSenderConfig {
17    /// HTTP request timeout (default: 30s).
18    pub timeout: Duration,
19    /// If true, push sending errors abort execution.
20    pub fail_on_error: bool,
21    /// If true (default), reject push URLs that target loopback, private,
22    /// link-local, multicast, unspecified, or cloud-metadata addresses, and
23    /// URLs whose scheme is not http/https. Disable only when pushing to
24    /// local webhooks in trusted test environments.
25    pub validate_urls: bool,
26}
27
28impl Default for HttpPushSenderConfig {
29    fn default() -> Self {
30        HttpPushSenderConfig {
31            timeout: Duration::from_secs(30),
32            fail_on_error: false,
33            validate_urls: true,
34        }
35    }
36}
37
38impl HttpPushSender {
39    pub fn new(config: Option<HttpPushSenderConfig>) -> Self {
40        let config = config.unwrap_or_default();
41        let client = reqwest::Client::builder()
42            .timeout(config.timeout)
43            .build()
44            .expect("failed to create HTTP client");
45        HttpPushSender {
46            client,
47            fail_on_error: config.fail_on_error,
48            validate_urls: config.validate_urls,
49        }
50    }
51
52    /// Send an event to the push notification endpoint.
53    pub async fn send_push(
54        &self,
55        config: &TaskPushNotificationConfig,
56        event: StreamResponse,
57    ) -> Result<(), A2AError> {
58        if self.validate_urls {
59            validate_push_url(&config.url)?;
60        }
61
62        // Reject credentials containing CR/LF so they cannot inject
63        // additional headers into the outgoing request (BUG-34).
64        if let Some(ref token) = config.token {
65            if token.contains('\r') || token.contains('\n') {
66                return Err(A2AError::invalid_params(
67                    "push notification token must not contain CR/LF",
68                ));
69            }
70        }
71        if let Some(ref auth) = config.authentication {
72            if let Some(ref creds) = auth.credentials {
73                if creds.contains('\r') || creds.contains('\n') {
74                    return Err(A2AError::invalid_params(
75                        "push credentials must not contain CR/LF",
76                    ));
77                }
78            }
79        }
80
81        let body = match serde_json::to_vec(&event) {
82            Ok(b) => b,
83            Err(e) => return self.handle_error(format!("failed to serialize event: {e}")),
84        };
85
86        let mut request = self
87            .client
88            .post(&config.url)
89            .header("Content-Type", "application/json")
90            .body(body);
91
92        if let Some(ref token) = config.token {
93            request = request.header("A2A-Notification-Token", token);
94        }
95
96        if let Some(ref auth) = config.authentication {
97            if let Some(ref creds) = auth.credentials {
98                match auth.scheme.to_lowercase().as_str() {
99                    "bearer" => {
100                        request = request.header("Authorization", format!("Bearer {creds}"));
101                    }
102                    "basic" => {
103                        request = request.header("Authorization", format!("Basic {creds}"));
104                    }
105                    _ => {}
106                }
107            }
108        }
109
110        match request.send().await {
111            Ok(resp) => {
112                if !resp.status().is_success() {
113                    return self
114                        .handle_error(format!("push endpoint returned status: {}", resp.status()));
115                }
116                Ok(())
117            }
118            Err(e) => self.handle_error(format!("failed to send push notification: {e}")),
119        }
120    }
121
122    fn handle_error(&self, msg: String) -> Result<(), A2AError> {
123        if self.fail_on_error {
124            Err(A2AError::internal(&msg))
125        } else {
126            tracing::error!("{}", msg);
127            Ok(())
128        }
129    }
130}
131
132/// Validate a push notification URL against SSRF targets (BUG-54).
133///
134/// The URL scheme must be http/https and the host must not resolve to a
135/// loopback, private, link-local, multicast, or unspecified address, nor to a
136/// well-known cloud metadata endpoint.
137fn validate_push_url(url: &str) -> Result<(), A2AError> {
138    let parsed = reqwest::Url::parse(url)
139        .map_err(|_| A2AError::invalid_params("invalid push notification URL"))?;
140
141    if parsed.scheme() != "http" && parsed.scheme() != "https" {
142        return Err(A2AError::invalid_params("push URL must be http or https"));
143    }
144
145    if let Some(host) = parsed.host_str() {
146        // A trailing dot is an equivalent fully-qualified form: "localhost."
147        // resolves exactly as "localhost" does. Normalise it away, or every
148        // hostname below is bypassed by appending one character. IP literals
149        // are unaffected either way -- Url::parse already strips the dot for
150        // those, which is why "127.0.0.1." was blocked and "localhost." was
151        // not.
152        let host = host.strip_suffix('.').unwrap_or(host);
153        // Block well-known loopback, metadata, and unspecified hosts.
154        let blocked = [
155            "127.0.0.1",
156            "localhost",
157            "::1",
158            "[::1]",
159            "169.254.169.254",
160            "metadata.google.internal",
161            "metadata.azure.com",
162            "metadata.goog",
163            "0.0.0.0",
164        ];
165        if blocked.contains(&host) {
166            return Err(A2AError::invalid_params("push URL targets blocked host"));
167        }
168        // Block IP literals in restricted ranges (loopback, RFC 1918
169        // private, link-local, ULA, unspecified, multicast). For IPv6 URLs
170        // the host serializes WITH brackets ("[fc00::1]"), which never
171        // parses as an IpAddr — strip them first or every range check
172        // below is silently skipped.
173        let host_unbracketed = host
174            .strip_prefix('[')
175            .and_then(|h| h.strip_suffix(']'))
176            .unwrap_or(host);
177        if let Ok(ip) = host_unbracketed.parse::<std::net::IpAddr>() {
178            let blocked = match ip {
179                std::net::IpAddr::V4(v4) => {
180                    v4.is_loopback()
181                        || v4.is_private()
182                        || v4.is_link_local()
183                        || v4.is_unspecified()
184                        || v4.is_multicast()
185                }
186                std::net::IpAddr::V6(v6) => {
187                    // fc00::/7 unique local addresses (IPv6 "private").
188                    let is_ula = (v6.segments()[0] & 0xfe00) == 0xfc00;
189                    // IPv4-mapped IPv6 literals (::ffff:a.b.c.d) must be
190                    // checked against the IPv4 restrictions too, or they
191                    // bypass the loopback/private/link-local checks above.
192                    let mapped_v4_blocked = v6.to_ipv4_mapped().is_some_and(|v4| {
193                        v4.is_loopback()
194                            || v4.is_private()
195                            || v4.is_link_local()
196                            || v4.is_unspecified()
197                            || v4.is_multicast()
198                    });
199                    v6.is_loopback()
200                        || is_ula
201                        || mapped_v4_blocked
202                        || v6.is_unicast_link_local()
203                        || v6.is_unspecified()
204                        || v6.is_multicast()
205                }
206            };
207            if blocked {
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#[cfg(test)]
219mod tests {
220    use super::*;
221
222    use crate::test_util::install_crypto_provider;
223
224    #[test]
225    fn test_default_config() {
226        let config = HttpPushSenderConfig::default();
227        assert_eq!(config.timeout, Duration::from_secs(30));
228        assert!(!config.fail_on_error);
229        assert!(config.validate_urls);
230    }
231
232    #[test]
233    fn test_sender_new_default() {
234        install_crypto_provider();
235        let sender = HttpPushSender::new(None);
236        assert!(!sender.fail_on_error);
237        assert!(sender.validate_urls);
238    }
239
240    #[test]
241    fn test_sender_new_custom() {
242        install_crypto_provider();
243        let config = HttpPushSenderConfig {
244            timeout: Duration::from_secs(10),
245            fail_on_error: true,
246            validate_urls: false,
247        };
248        let sender = HttpPushSender::new(Some(config));
249        assert!(sender.fail_on_error);
250        assert!(!sender.validate_urls);
251    }
252
253    #[test]
254    fn test_handle_error_fail_on_error() {
255        install_crypto_provider();
256        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
257            timeout: Duration::from_secs(5),
258            fail_on_error: true,
259            validate_urls: true,
260        }));
261        let result = sender.handle_error("test error".to_string());
262        assert!(result.is_err());
263    }
264
265    #[test]
266    fn test_handle_error_ignore() {
267        install_crypto_provider();
268        let sender = HttpPushSender::new(None);
269        let result = sender.handle_error("test error".to_string());
270        assert!(result.is_ok());
271    }
272
273    fn sample_status_update() -> StreamResponse {
274        use a2a::event::*;
275        StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
276            task_id: "t1".into(),
277            context_id: "c1".into(),
278            status: TaskStatus {
279                state: TaskState::Working,
280                message: None,
281                timestamp: None,
282            },
283            metadata: None,
284        })
285    }
286
287    fn sample_config(url: &str) -> TaskPushNotificationConfig {
288        TaskPushNotificationConfig {
289            task_id: String::new(),
290            url: url.to_string(),
291            id: None,
292            token: None,
293            authentication: None,
294            tenant: None,
295        }
296    }
297
298    #[tokio::test]
299    async fn test_send_push_connection_refused_no_fail() {
300        install_crypto_provider();
301        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
302            validate_urls: false,
303            ..Default::default()
304        }));
305        let config = TaskPushNotificationConfig {
306            task_id: String::new(),
307            url: "http://127.0.0.1:1/callback".to_string(),
308            id: None,
309            token: Some("tok".to_string()),
310            authentication: Some(AuthenticationInfo {
311                scheme: "bearer".to_string(),
312                credentials: Some("secret".to_string()),
313            }),
314            tenant: None,
315        };
316        let result = sender.send_push(&config, sample_status_update()).await;
317        // Should be Ok because fail_on_error is false
318        assert!(result.is_ok());
319    }
320
321    #[tokio::test]
322    async fn test_send_push_connection_refused_fail() {
323        install_crypto_provider();
324        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
325            timeout: std::time::Duration::from_millis(100),
326            fail_on_error: true,
327            validate_urls: false,
328        }));
329        let config = sample_config("http://127.0.0.1:1/callback");
330        let result = sender.send_push(&config, sample_status_update()).await;
331        assert!(result.is_err());
332    }
333
334    #[tokio::test]
335    async fn test_send_push_with_basic_auth() {
336        install_crypto_provider();
337        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
338            validate_urls: false,
339            ..Default::default()
340        }));
341        let config = TaskPushNotificationConfig {
342            task_id: String::new(),
343            url: "http://127.0.0.1:1/callback".to_string(),
344            id: None,
345            token: None,
346            authentication: Some(AuthenticationInfo {
347                scheme: "basic".to_string(),
348                credentials: Some("dXNlcjpwYXNz".to_string()),
349            }),
350            tenant: None,
351        };
352        let result = sender.send_push(&config, sample_status_update()).await;
353        // Will fail to connect but exercises basic auth header path
354        assert!(result.is_ok()); // fail_on_error=false
355    }
356
357    #[tokio::test]
358    async fn test_send_push_with_unknown_auth_scheme() {
359        install_crypto_provider();
360        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
361            validate_urls: false,
362            ..Default::default()
363        }));
364        let config = TaskPushNotificationConfig {
365            task_id: String::new(),
366            url: "http://127.0.0.1:1/callback".to_string(),
367            id: None,
368            token: None,
369            authentication: Some(AuthenticationInfo {
370                scheme: "custom".to_string(),
371                credentials: Some("cred".to_string()),
372            }),
373            tenant: None,
374        };
375        let result = sender.send_push(&config, sample_status_update()).await;
376        assert!(result.is_ok());
377    }
378
379    #[test]
380    fn test_validate_push_url_rejects_non_http_schemes() {
381        for url in [
382            "file:///etc/passwd",
383            "ftp://example.com/hook",
384            "gopher://example.com/",
385        ] {
386            let err = validate_push_url(url).unwrap_err();
387            assert_eq!(err.code, error_code::INVALID_PARAMS);
388        }
389    }
390
391    #[test]
392    fn test_validate_push_url_rejects_loopback() {
393        for url in [
394            "http://127.0.0.1:1/callback",
395            "http://localhost/callback",
396            "http://[::1]/callback",
397            "http://0.0.0.0/callback",
398        ] {
399            assert!(
400                validate_push_url(url).is_err(),
401                "expected {url} to be rejected"
402            );
403        }
404    }
405
406    #[test]
407    fn test_validate_push_url_rejects_private_and_link_local() {
408        for url in [
409            "http://10.0.0.1/callback",
410            "http://192.168.1.1/callback",
411            "http://172.16.0.1/callback",
412            "http://169.254.169.254/latest/meta-data/",
413            "http://169.254.1.1/callback",
414            "http://metadata.google.internal/callback",
415        ] {
416            assert!(
417                validate_push_url(url).is_err(),
418                "expected {url} to be rejected"
419            );
420        }
421    }
422
423    #[test]
424    fn test_validate_push_url_rejects_trailing_dot_hosts() {
425        // "localhost." is the fully-qualified spelling of "localhost" and
426        // resolves identically, so it must not slip past the host blocklist.
427        for url in [
428            "http://localhost./hook",
429            "http://LocalHost./hook",
430            "https://localhost./hook",
431            "http://metadata.google.internal./hook",
432            "http://metadata.azure.com./hook",
433        ] {
434            assert!(
435                validate_push_url(url).is_err(),
436                "{url} must be blocked, but was allowed"
437            );
438        }
439    }
440
441    #[test]
442    fn test_validate_push_url_rejects_alternate_ipv4_spellings() {
443        // These are blocked only because Url::parse normalises them to
444        // 127.0.0.1 before the range check sees them. Pin that, so the
445        // normalisation cannot be regressed away unnoticed.
446        for url in [
447            "http://2130706433/hook",         // decimal
448            "http://0x7f.0.0.1/hook",         // hex octet
449            "http://127.1/hook",              // short form
450            "http://127.0.0.1./hook",         // trailing dot on a literal
451            "http://[::ffff:127.0.0.1]/hook", // IPv4-mapped IPv6
452        ] {
453            assert!(
454                validate_push_url(url).is_err(),
455                "{url} must be blocked, but was allowed"
456            );
457        }
458    }
459
460    #[test]
461    fn test_validate_push_url_accepts_public_urls() {
462        for url in [
463            "http://example.com/callback",
464            "https://example.com/callback",
465            "https://hooks.example.com/abc?x=1",
466        ] {
467            assert!(
468                validate_push_url(url).is_ok(),
469                "expected {url} to be accepted"
470            );
471        }
472    }
473
474    #[tokio::test]
475    async fn test_send_push_rejects_blocked_url_even_without_fail_on_error() {
476        install_crypto_provider();
477        let sender = HttpPushSender::new(None); // validate_urls = true
478        let config = sample_config("http://127.0.0.1:1/callback");
479        let result = sender.send_push(&config, sample_status_update()).await;
480        // Validation errors are always returned, regardless of fail_on_error.
481        assert!(result.is_err());
482        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
483    }
484
485    #[tokio::test]
486    async fn test_send_push_rejects_crlf_credentials() {
487        install_crypto_provider();
488        let sender = HttpPushSender::new(None);
489        let config = TaskPushNotificationConfig {
490            task_id: String::new(),
491            url: "https://example.com/callback".to_string(),
492            id: None,
493            token: None,
494            authentication: Some(AuthenticationInfo {
495                scheme: "bearer".to_string(),
496                credentials: Some("secret\r\nX-Injected: 1".to_string()),
497            }),
498            tenant: None,
499        };
500        let result = sender.send_push(&config, sample_status_update()).await;
501        assert!(result.is_err());
502        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
503    }
504
505    #[tokio::test]
506    async fn test_send_push_rejects_crlf_token() {
507        install_crypto_provider();
508        let sender = HttpPushSender::new(None);
509        let config = TaskPushNotificationConfig {
510            task_id: String::new(),
511            url: "https://example.com/callback".to_string(),
512            id: None,
513            token: Some("tok\nX-Injected: 1".to_string()),
514            authentication: None,
515            tenant: None,
516        };
517        let result = sender.send_push(&config, sample_status_update()).await;
518        assert!(result.is_err());
519        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
520    }
521
522    #[test]
523    fn test_validate_push_url_rejects_invalid_url() {
524        assert!(validate_push_url("not a url").is_err());
525        assert!(validate_push_url("").is_err());
526        // Empty authority fails URL parsing outright (EmptyHost).
527        assert!(validate_push_url("http://").is_err());
528    }
529
530    #[test]
531    fn test_validate_push_url_rejects_ipv6_restricted_ranges() {
532        for url in [
533            "http://[fc00::1]/cb",
534            "http://[fd12:3456:789a::1]/cb",
535            "http://[fe80::1]/cb",
536            "http://[ff02::1]/cb",
537            "http://[::]/cb",
538        ] {
539            let result = validate_push_url(url);
540            assert!(result.is_err(), "expected {url} to be rejected");
541            assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
542        }
543    }
544
545    #[test]
546    fn test_validate_push_url_rejects_ipv4_mapped_ipv6() {
547        // ::ffff:a.b.c.d literals must inherit the IPv4 restrictions.
548        for url in [
549            "http://[::ffff:127.0.0.1]/cb",
550            "http://[::ffff:10.0.0.1]/cb",
551        ] {
552            let result = validate_push_url(url);
553            assert!(result.is_err(), "expected {url} to be rejected");
554            assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
555        }
556    }
557
558    #[test]
559    fn test_validate_push_url_rejects_ipv4_multicast() {
560        let result = validate_push_url("http://224.0.0.1/cb");
561        assert!(result.is_err());
562        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
563    }
564
565    #[test]
566    fn test_validate_push_url_accepts_global_ipv6() {
567        assert!(validate_push_url("http://[2606:4700::1111]/cb").is_ok());
568    }
569}