1use std::future::Future;
18use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
19use std::pin::Pin;
20
21use a2a_protocol_types::error::{A2aError, A2aResult};
22use a2a_protocol_types::events::StreamResponse;
23use a2a_protocol_types::push::TaskPushNotificationConfig;
24use bytes::Bytes;
25use http_body_util::Full;
26use hyper_util::client::legacy::connect::HttpConnector;
27use hyper_util::client::legacy::Client;
28use hyper_util::rt::TokioExecutor;
29
30#[cfg(not(feature = "tls-rustls"))]
35type PushHttpClient = Client<HttpConnector, Full<Bytes>>;
36#[cfg(feature = "tls-rustls")]
37type PushHttpClient = Client<hyper_rustls::HttpsConnector<HttpConnector>, Full<Bytes>>;
38
39#[cfg(feature = "tls-rustls")]
43fn push_tls_config() -> rustls::ClientConfig {
44 let mut roots = rustls::RootCertStore::empty();
45 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
46 rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
47 rustls::crypto::ring::default_provider(),
48 ))
49 .with_safe_default_protocol_versions()
50 .expect("ring provider supports the rustls default protocol versions")
51 .with_root_certificates(roots)
52 .with_no_client_auth()
53}
54
55#[cfg(feature = "tls-rustls")]
57fn build_push_https_client(tls_config: rustls::ClientConfig) -> PushHttpClient {
58 let mut http = HttpConnector::new();
59 http.enforce_http(false);
62 http.set_nodelay(true);
63 let https = hyper_rustls::HttpsConnectorBuilder::new()
64 .with_tls_config(tls_config)
65 .https_or_http()
66 .enable_all_versions()
67 .wrap_connector(http);
68 Client::builder(TokioExecutor::new()).build(https)
69}
70
71fn build_push_http_client() -> PushHttpClient {
74 #[cfg(not(feature = "tls-rustls"))]
75 {
76 Client::builder(TokioExecutor::new()).build_http()
77 }
78 #[cfg(feature = "tls-rustls")]
79 {
80 build_push_https_client(push_tls_config())
81 }
82}
83
84pub trait PushSender: Send + Sync + 'static {
88 fn send<'a>(
94 &'a self,
95 url: &'a str,
96 event: &'a StreamResponse,
97 config: &'a TaskPushNotificationConfig,
98 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>>;
99
100 fn allows_private_urls(&self) -> bool {
106 false
107 }
108}
109
110const DEFAULT_PUSH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
112
113#[derive(Debug, Clone)]
130pub struct PushRetryPolicy {
131 pub max_attempts: usize,
133 pub backoff: Vec<std::time::Duration>,
138}
139
140impl Default for PushRetryPolicy {
141 fn default() -> Self {
142 Self {
143 max_attempts: 3,
144 backoff: vec![
145 std::time::Duration::from_secs(1),
146 std::time::Duration::from_secs(2),
147 ],
148 }
149 }
150}
151
152impl PushRetryPolicy {
153 #[must_use]
155 pub const fn with_max_attempts(mut self, max: usize) -> Self {
156 self.max_attempts = max;
157 self
158 }
159
160 #[must_use]
162 pub fn with_backoff(mut self, backoff: Vec<std::time::Duration>) -> Self {
163 self.backoff = backoff;
164 self
165 }
166}
167
168#[derive(Debug)]
205pub struct HttpPushSender {
206 client: PushHttpClient,
207 request_timeout: std::time::Duration,
208 retry_policy: PushRetryPolicy,
209 allow_private_urls: bool,
211}
212
213impl Default for HttpPushSender {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219impl HttpPushSender {
220 #[must_use]
223 pub fn new() -> Self {
224 Self::with_timeout(DEFAULT_PUSH_REQUEST_TIMEOUT)
225 }
226
227 #[must_use]
229 pub fn with_timeout(request_timeout: std::time::Duration) -> Self {
230 let client = build_push_http_client();
231 Self {
232 client,
233 request_timeout,
234 retry_policy: PushRetryPolicy::default(),
235 allow_private_urls: false,
236 }
237 }
238
239 #[cfg(feature = "tls-rustls")]
250 #[must_use]
251 pub fn with_tls_config(tls_config: rustls::ClientConfig) -> Self {
252 Self {
253 client: build_push_https_client(tls_config),
254 request_timeout: DEFAULT_PUSH_REQUEST_TIMEOUT,
255 retry_policy: PushRetryPolicy::default(),
256 allow_private_urls: false,
257 }
258 }
259
260 #[must_use]
262 pub fn with_retry_policy(mut self, policy: PushRetryPolicy) -> Self {
263 self.retry_policy = policy;
264 self
265 }
266
267 #[must_use]
272 pub const fn allow_private_urls(mut self) -> Self {
273 self.allow_private_urls = true;
274 self
275 }
276}
277
278#[allow(clippy::missing_const_for_fn)] fn is_private_v4(v4: Ipv4Addr) -> bool {
282 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) }
288
289fn embedded_ipv4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
298 if let Some(v4) = v6.to_ipv4_mapped() {
299 return Some(v4);
300 }
301 let v4_from = |g: u16, h: u16| {
302 let [a, b] = g.to_be_bytes();
303 let [c, d] = h.to_be_bytes();
304 Ipv4Addr::new(a, b, c, d)
305 };
306 match v6.segments() {
307 [0x0064, 0xff9b, 0, 0, 0, 0, g, h] => Some(v4_from(g, h)),
309 [0, 0, 0, 0, 0, 0, g, h] if !(g == 0 && (h == 0 || h == 1)) => Some(v4_from(g, h)),
311 _ => None,
312 }
313}
314
315#[allow(clippy::missing_const_for_fn)] fn is_private_ip(ip: IpAddr) -> bool {
318 match ip {
319 IpAddr::V4(v4) => is_private_v4(v4),
320 IpAddr::V6(v6) => {
321 if let Some(v4) = embedded_ipv4(v6) {
325 return is_private_v4(v4);
326 }
327 v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00
331 || (v6.segments()[0] & 0xffc0) == 0xfe80
333 }
334 }
335}
336
337#[allow(clippy::case_sensitive_file_extension_comparisons)] pub(crate) fn validate_webhook_url(url: &str) -> A2aResult<()> {
343 let uri: hyper::Uri = url
345 .parse()
346 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
347
348 match uri.scheme_str() {
350 Some("http" | "https") => {}
351 Some(other) => {
352 return Err(A2aError::invalid_params(format!(
353 "webhook URL has unsupported scheme: {other} (expected http or https)"
354 )));
355 }
356 None => {
357 return Err(A2aError::invalid_params(
358 "webhook URL missing scheme (expected http:// or https://)",
359 ));
360 }
361 }
362
363 let host = uri
364 .host()
365 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
366
367 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
369
370 if let Ok(ip) = host_bare.parse::<IpAddr>() {
372 if is_private_ip(ip) {
373 return Err(A2aError::invalid_params(format!(
374 "webhook URL targets private/loopback address: {host}"
375 )));
376 }
377 }
378
379 let host_lower = host.to_ascii_lowercase();
381 if host_lower == "localhost"
382 || host_lower.ends_with(".local")
383 || host_lower.ends_with(".internal")
384 {
385 return Err(A2aError::invalid_params(format!(
386 "webhook URL targets local/internal hostname: {host}"
387 )));
388 }
389
390 Ok(())
391}
392
393pub(crate) async fn validate_webhook_url_with_dns(url: &str) -> A2aResult<Option<SocketAddr>> {
410 validate_webhook_url(url)?;
412
413 let uri: hyper::Uri = url
415 .parse()
416 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
417
418 let host = uri
419 .host()
420 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
421
422 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
424
425 if host_bare.parse::<IpAddr>().is_ok() {
428 return Ok(None);
429 }
430
431 let port = uri.port_u16().unwrap_or_else(|| {
433 if uri.scheme_str() == Some("https") {
434 443
435 } else {
436 80
437 }
438 });
439
440 let addr = format!("{host_bare}:{port}");
441 let resolved = tokio::net::lookup_host(&addr).await.map_err(|e| {
442 A2aError::invalid_params(format!(
443 "webhook URL hostname could not be resolved: {host_bare}: {e}"
444 ))
445 })?;
446
447 let mut pinned: Option<SocketAddr> = None;
448 for socket_addr in resolved {
449 let ip = socket_addr.ip();
450 if is_private_ip(ip) {
451 return Err(A2aError::invalid_params(format!(
452 "webhook URL hostname {host_bare} resolves to private/loopback address: {ip}"
453 )));
454 }
455 if pinned.is_none() {
456 pinned = Some(socket_addr);
457 }
458 }
459
460 pinned
461 .ok_or_else(|| {
462 A2aError::invalid_params(format!(
463 "webhook URL hostname {host_bare} did not resolve to any addresses"
464 ))
465 })
466 .map(Some)
467}
468
469fn rewrite_uri_with_pinned_addr(url: &str, pinned: SocketAddr) -> A2aResult<hyper::Uri> {
479 let uri: hyper::Uri = url
480 .parse()
481 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
482
483 let scheme = uri
484 .scheme_str()
485 .ok_or_else(|| A2aError::invalid_params("webhook URL missing scheme"))?;
486
487 let host_str = match pinned.ip() {
489 IpAddr::V4(v4) => v4.to_string(),
490 IpAddr::V6(v6) => format!("[{v6}]"),
491 };
492
493 let path_and_query = uri
494 .path_and_query()
495 .map_or_else(|| "/".to_string(), std::string::ToString::to_string);
496
497 let rewritten = format!(
498 "{scheme}://{host_str}:{port}{path_and_query}",
499 port = pinned.port()
500 );
501
502 rewritten
503 .parse()
504 .map_err(|e| A2aError::invalid_params(format!("could not rewrite webhook URL: {e}")))
505}
506
507fn host_header_from_url(url: &str) -> A2aResult<String> {
513 let uri: hyper::Uri = url
514 .parse()
515 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
516 let host = uri
517 .host()
518 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
519 Ok(uri
520 .port_u16()
521 .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")))
522}
523
524const fn pin_target(is_https: bool, pinned_addr: Option<SocketAddr>) -> Option<SocketAddr> {
534 match pinned_addr {
535 Some(addr) if !is_https => Some(addr),
536 _ => None,
537 }
538}
539
540fn validate_header_value(value: &str, name: &str) -> A2aResult<()> {
542 if value.contains('\r') || value.contains('\n') {
543 return Err(A2aError::invalid_params(format!(
544 "{name} contains invalid characters (CR/LF)"
545 )));
546 }
547 Ok(())
548}
549
550#[allow(clippy::manual_async_fn, clippy::too_many_lines)]
551impl PushSender for HttpPushSender {
552 fn allows_private_urls(&self) -> bool {
553 self.allow_private_urls
554 }
555
556 fn send<'a>(
557 &'a self,
558 url: &'a str,
559 event: &'a StreamResponse,
560 config: &'a TaskPushNotificationConfig,
561 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
562 Box::pin(async move {
563 trace_info!(url, "delivering push notification");
564
565 let is_https = url
566 .split_once("://")
567 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
568
569 #[cfg(not(feature = "tls-rustls"))]
575 if is_https {
576 return Err(A2aError::internal(
577 "this build of HttpPushSender delivers over HTTP only and cannot reach an \
578 https:// webhook; enable the `tls-rustls` feature (on by default in \
579 a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
580 ));
581 }
582
583 let pinned_addr = if self.allow_private_urls {
587 None
588 } else {
589 validate_webhook_url_with_dns(url).await?
590 };
591
592 let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
600 Some(addr) => (
601 Some(rewrite_uri_with_pinned_addr(url, addr)?),
602 Some(host_header_from_url(url)?),
603 ),
604 None => (None, None),
605 };
606
607 if let Some(ref auth) = config.authentication {
609 if let Some(ref credentials) = auth.credentials {
610 validate_header_value(credentials, "authentication credentials")?;
611 }
612 validate_header_value(&auth.scheme, "authentication scheme")?;
613 }
614 if let Some(ref token) = config.token {
615 validate_header_value(token, "notification token")?;
616 }
617
618 let body_bytes: Bytes = serde_json::to_vec(event)
619 .map(Bytes::from)
620 .map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
621
622 let mut last_err = String::new();
623
624 for attempt in 0..self.retry_policy.max_attempts {
625 let mut builder = hyper::Request::builder()
626 .method(hyper::Method::POST)
627 .header("content-type", "application/json");
628
629 if let Some(uri) = pinned_uri.as_ref() {
630 builder = builder.uri(uri.clone());
631 if let Some(host) = pinned_host_header.as_deref() {
632 builder = builder.header("host", host);
633 }
634 } else {
635 builder = builder.uri(url);
636 }
637
638 if let Some(ref auth) = config.authentication {
645 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
646 Some("Bearer")
647 } else if auth.scheme.eq_ignore_ascii_case("basic") {
648 Some("Basic")
649 } else {
650 None
651 };
652 match (canonical_scheme, auth.credentials.as_deref()) {
653 (Some(prefix), Some(credentials)) => {
654 builder =
655 builder.header("authorization", format!("{prefix} {credentials}"));
656 }
657 (Some(_), None) => {
658 trace_warn!(
659 scheme = auth.scheme.as_str(),
660 "authentication scheme has no credentials; no auth header set"
661 );
662 }
663 (None, _) => {
664 trace_warn!(
665 scheme = auth.scheme.as_str(),
666 "unknown authentication scheme; no auth header set"
667 );
668 }
669 }
670 }
671
672 if let Some(ref token) = config.token {
680 builder = builder
681 .header("x-a2a-notification-token", token.as_str())
682 .header("a2a-notification-token", token.as_str());
683 }
684
685 let req = builder
686 .body(Full::new(body_bytes.clone()))
687 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
688
689 let request_result =
690 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
691
692 match request_result {
693 Ok(Ok(resp)) if resp.status().is_success() => {
694 trace_debug!(url, "push notification delivered");
695 return Ok(());
696 }
697 Ok(Ok(resp)) => {
698 let status = resp.status();
699 let retryable = status.is_server_error()
705 || status == hyper::StatusCode::REQUEST_TIMEOUT
706 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
707 if !retryable {
708 trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
709 return Err(A2aError::internal(format!(
710 "push notification got non-retryable HTTP {status}"
711 )));
712 }
713 last_err = format!("push notification got HTTP {status}");
714 trace_warn!(url, attempt, status = %status, "push delivery failed");
715 }
716 Ok(Err(e)) => {
717 last_err = format!("push notification failed: {e}");
718 trace_warn!(url, attempt, error = %e, "push delivery error");
719 }
720 Err(_) => {
721 last_err = format!(
722 "push notification timed out after {}s",
723 self.request_timeout.as_secs()
724 );
725 trace_warn!(url, attempt, "push delivery timed out");
726 }
727 }
728
729 if attempt < self.retry_policy.max_attempts - 1 {
731 let delay = self
732 .retry_policy
733 .backoff
734 .get(attempt)
735 .or_else(|| self.retry_policy.backoff.last());
736 if let Some(delay) = delay {
737 tokio::time::sleep(*delay).await;
738 }
739 }
740 }
741
742 Err(A2aError::internal(last_err))
743 })
744 }
745}
746
747#[cfg(test)]
750mod tests {
751 use super::*;
752
753 #[test]
755 fn push_retry_policy_with_max_attempts() {
756 let policy = PushRetryPolicy::default().with_max_attempts(5);
757 assert_eq!(policy.max_attempts, 5);
758 assert_eq!(policy.backoff.len(), 2);
760 }
761
762 #[test]
764 fn push_retry_policy_with_backoff() {
765 let backoff = vec![
766 std::time::Duration::from_millis(100),
767 std::time::Duration::from_millis(500),
768 std::time::Duration::from_secs(1),
769 ];
770 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
771 assert_eq!(policy.backoff, backoff);
772 assert_eq!(policy.max_attempts, 3);
774 }
775
776 #[test]
778 fn http_push_sender_with_retry_policy() {
779 let policy = PushRetryPolicy::default().with_max_attempts(10);
780 let sender = HttpPushSender::new().with_retry_policy(policy);
781 assert_eq!(sender.retry_policy.max_attempts, 10);
782 }
783
784 #[test]
786 fn rejects_url_without_host() {
787 assert!(validate_webhook_url("http:///path").is_err());
788 }
789
790 #[test]
792 fn http_push_sender_allow_private_urls() {
793 let sender = HttpPushSender::new().allow_private_urls();
794 assert!(sender.allow_private_urls);
795 }
796
797 #[test]
799 fn http_push_sender_default() {
800 let sender = HttpPushSender::default();
801 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
802 assert!(!sender.allow_private_urls);
803 }
804
805 #[test]
807 fn push_retry_policy_default() {
808 let policy = PushRetryPolicy::default();
809 assert_eq!(policy.max_attempts, 3);
810 assert_eq!(policy.backoff.len(), 2);
811 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
812 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
813 }
814
815 #[test]
816 fn rejects_loopback_ipv4() {
817 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
818 }
819
820 #[test]
821 fn rejects_private_10_range() {
822 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
823 }
824
825 #[test]
826 fn rejects_private_172_range() {
827 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
828 }
829
830 #[test]
831 fn rejects_private_192_168_range() {
832 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
833 }
834
835 #[test]
836 fn rejects_link_local() {
837 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
838 }
839
840 #[test]
841 fn rejects_localhost() {
842 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
843 }
844
845 #[test]
846 fn rejects_dot_local() {
847 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
848 }
849
850 #[test]
851 fn rejects_dot_internal() {
852 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
853 }
854
855 #[test]
856 fn rejects_ipv6_loopback() {
857 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
858 }
859
860 #[test]
867 fn rejects_ipv4_mapped_loopback() {
868 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
869 }
870
871 #[test]
872 fn rejects_ipv4_mapped_metadata() {
873 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
874 }
875
876 #[test]
877 fn rejects_ipv4_mapped_private() {
878 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
879 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
880 }
881
882 #[test]
883 fn rejects_ipv4_compatible_loopback() {
884 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
886 }
887
888 #[test]
889 fn rejects_nat64_wellknown_prefix_to_private() {
890 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
892 }
893
894 #[test]
895 fn accepts_ipv4_mapped_public() {
896 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
898 }
899
900 #[test]
908 fn is_private_v4_cgnat_boundary() {
909 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
911 assert!(is_private_v4("100.127.255.255".parse().unwrap())); assert!(!is_private_v4("100.0.0.1".parse().unwrap())); assert!(!is_private_v4("100.128.0.1".parse().unwrap())); assert!(!is_private_v4("5.64.0.1".parse().unwrap()));
917 }
918
919 #[test]
920 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
921 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
922 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
923
924 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
926 assert_eq!(
927 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
928 v4("169.254.169.254")
929 );
930 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
933 assert_eq!(embedded_ipv4(v6("::")), None);
934 assert_eq!(embedded_ipv4(v6("::1")), None);
935 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
937 }
938
939 #[test]
940 fn accepts_public_url() {
941 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
942 }
943
944 #[test]
945 fn accepts_public_ip() {
946 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
947 }
948
949 #[test]
950 fn rejects_header_with_crlf() {
951 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
952 }
953
954 #[test]
955 fn rejects_header_with_cr() {
956 assert!(validate_header_value("token\rvalue", "test").is_err());
957 }
958
959 #[test]
960 fn rejects_header_with_lf() {
961 assert!(validate_header_value("token\nvalue", "test").is_err());
962 }
963
964 #[test]
965 fn accepts_clean_header_value() {
966 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
967 }
968
969 #[test]
970 fn rejects_url_without_scheme() {
971 assert!(validate_webhook_url("example.com/webhook").is_err());
972 }
973
974 #[test]
975 fn rejects_ftp_scheme() {
976 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
977 }
978
979 #[test]
980 fn rejects_file_scheme() {
981 assert!(validate_webhook_url("file:///etc/passwd").is_err());
982 }
983
984 #[test]
985 fn accepts_http_scheme() {
986 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
987 }
988
989 #[test]
990 fn rejects_cgnat_range() {
991 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
992 }
993
994 #[test]
995 fn rejects_unspecified_ipv4() {
996 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
997 }
998
999 #[test]
1000 fn rejects_ipv6_unique_local() {
1001 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1002 }
1003
1004 #[test]
1005 fn rejects_ipv6_link_local() {
1006 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1007 }
1008
1009 #[tokio::test]
1012 async fn dns_rejects_loopback_ip_literal() {
1013 let result = validate_webhook_url_with_dns("http://127.0.0.1:8080/webhook").await;
1015 assert!(result.is_err(), "loopback IP should be rejected");
1016 }
1017
1018 #[tokio::test]
1019 async fn dns_rejects_private_ip_literal() {
1020 let result = validate_webhook_url_with_dns("http://10.0.0.1/webhook").await;
1021 assert!(result.is_err(), "private IP should be rejected");
1022 }
1023
1024 #[tokio::test]
1025 async fn dns_rejects_localhost_hostname() {
1026 let result = validate_webhook_url_with_dns("http://localhost:8080/webhook").await;
1028 assert!(result.is_err(), "localhost should be rejected");
1029 }
1030
1031 #[tokio::test]
1032 async fn dns_rejects_invalid_scheme() {
1033 let result = validate_webhook_url_with_dns("ftp://example.com/webhook").await;
1034 assert!(result.is_err(), "ftp scheme should be rejected");
1035 }
1036
1037 #[tokio::test]
1038 async fn dns_rejects_missing_host() {
1039 let result = validate_webhook_url_with_dns("http:///path").await;
1040 assert!(result.is_err(), "missing host should be rejected");
1041 }
1042
1043 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1044 async fn dns_rejects_unresolvable_hostname() {
1045 let (tx, rx) = tokio::sync::oneshot::channel();
1048 std::thread::spawn(move || {
1049 let rt = tokio::runtime::Builder::new_current_thread()
1050 .enable_all()
1051 .build()
1052 .unwrap();
1053 let result = rt.block_on(validate_webhook_url_with_dns(
1054 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1055 ));
1056 let _ = tx.send(result);
1057 });
1058 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1059 Ok(Ok(result)) => {
1060 assert!(result.is_err(), "unresolvable hostname should be rejected");
1061 }
1062 Ok(Err(_)) => panic!("sender dropped without sending"),
1063 Err(_elapsed) => {
1064 }
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn dns_accepts_ip_literal_public() {
1071 let result = validate_webhook_url_with_dns("https://203.0.113.1/webhook").await;
1074 assert!(
1075 matches!(result, Ok(None)),
1076 "public IP literal should be accepted with no pinning (got {result:?})",
1077 );
1078 }
1079
1080 #[test]
1083 fn rewrite_uri_preserves_scheme_path_and_query() {
1084 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1085 let rewritten =
1086 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1087 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1088 }
1089
1090 #[test]
1091 fn rewrite_uri_uses_ipv6_brackets() {
1092 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1093 let rewritten =
1094 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1095 assert!(
1097 rewritten.to_string().contains("[2001:db8::1]:443"),
1098 "IPv6 literal should be bracketed: {rewritten}",
1099 );
1100 }
1101
1102 #[test]
1103 fn rewrite_uri_default_path_when_missing() {
1104 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1105 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1106 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1107 }
1108
1109 #[test]
1110 fn host_header_includes_port_when_present() {
1111 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1112 assert_eq!(host, "example.com:8080");
1113 }
1114
1115 #[test]
1116 fn host_header_omits_default_port() {
1117 let host = host_header_from_url("https://example.com/webhook").unwrap();
1118 assert_eq!(host, "example.com");
1119 }
1120
1121 #[test]
1122 fn host_header_from_url_rejects_missing_host() {
1123 let result = host_header_from_url("http:///path");
1124 assert!(result.is_err());
1125 }
1126
1127 #[test]
1128 fn pin_target_pins_http_but_not_https() {
1129 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1130 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1132 assert_eq!(pin_target(true, Some(addr)), None);
1135 assert_eq!(pin_target(false, None), None);
1137 assert_eq!(pin_target(true, None), None);
1138 }
1139
1140 fn dummy_event() -> StreamResponse {
1143 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1144 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1145 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1146 task_id: TaskId::new("t1"),
1147 context_id: ContextId::new("c1"),
1148 status: TaskStatus::with_timestamp(TaskState::Working),
1149 metadata: None,
1150 })
1151 }
1152
1153 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1154 TaskPushNotificationConfig {
1155 tenant: None,
1156 id: Some("cfg".to_owned()),
1157 task_id: Some("t1".to_owned()),
1158 url: url.to_owned(),
1159 token: None,
1160 authentication: None,
1161 }
1162 }
1163
1164 #[cfg(not(feature = "tls-rustls"))]
1167 #[tokio::test]
1168 async fn https_without_tls_feature_fails_fast() {
1169 let sender = HttpPushSender::new();
1170 let event = dummy_event();
1171 let config = dummy_config("https://example.com/webhook");
1172 let err = sender
1173 .send(&config.url, &event, &config)
1174 .await
1175 .expect_err("https must fail fast without the tls-rustls feature");
1176 assert!(
1177 err.to_string().contains("HTTP only"),
1178 "expected the HTTP-only error, got: {err}"
1179 );
1180 }
1181
1182 #[cfg(feature = "tls-rustls")]
1186 #[tokio::test]
1187 async fn https_with_tls_feature_still_enforces_ssrf() {
1188 let sender = HttpPushSender::new();
1189 let event = dummy_event();
1190 let config = dummy_config("https://127.0.0.1:8443/webhook");
1191 let err = sender
1192 .send(&config.url, &event, &config)
1193 .await
1194 .expect_err("https to a loopback address must be rejected by SSRF");
1195 let msg = err.to_string();
1196 assert!(
1197 msg.contains("private/loopback") || msg.contains("loopback"),
1198 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1199 );
1200 }
1201}