1use a2a::*;
5use reqwest::dns::{Addrs, Name, Resolve, Resolving};
6use std::net::SocketAddr;
7use std::sync::Arc;
8use std::time::Duration;
9
10pub struct HttpPushSender {
14 client: reqwest::Client,
15 fail_on_error: bool,
16 validate_urls: bool,
17}
18
19pub struct HttpPushSenderConfig {
21 pub timeout: Duration,
23 pub fail_on_error: bool,
25 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 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 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 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
161pub(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 let host = host.strip_suffix('.').unwrap_or(host);
182 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 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
218pub(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 let is_ula = (v6.segments()[0] & 0xfe00) == 0xfc00;
241 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
261fn connectable_addrs(addrs: impl Iterator<Item = SocketAddr>) -> Vec<SocketAddr> {
265 addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect()
266}
267
268struct 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 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
306fn 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 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 assert!(result.is_ok()); }
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 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 for url in [
556 "http://2130706433/hook", "http://0x7f.0.0.1/hook", "http://127.1/hook", "http://127.0.0.1./hook", "http://[::ffff:127.0.0.1]/hook", ] {
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 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 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 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); let config = sample_config("http://127.0.0.1:1/callback");
633 let result = sender.send_push(&config, sample_status_update()).await;
634 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 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 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 let addrs: Vec<SocketAddr> = vec![
730 "127.0.0.1:443".parse().unwrap(), "10.0.0.5:443".parse().unwrap(), "169.254.169.254:80".parse().unwrap(), "93.184.216.34:443".parse().unwrap(), "[fc00::1]:443".parse().unwrap(), "[2606:4700::1111]:443".parse().unwrap(), ];
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 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 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 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 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}