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
393fn webhook_port(uri: &hyper::Uri) -> u16 {
422 if let Some(explicit) = uri.port_u16() {
423 return explicit;
424 }
425 if uri.scheme_str() == Some("https") {
426 443
427 } else {
428 80
429 }
430}
431
432pub(crate) async fn validate_webhook_url_with_dns(url: &str) -> A2aResult<Option<SocketAddr>> {
433 validate_webhook_url(url)?;
435
436 let uri: hyper::Uri = url
438 .parse()
439 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
440
441 let host = uri
442 .host()
443 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
444
445 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
447
448 if host_bare.parse::<IpAddr>().is_ok() {
451 return Ok(None);
452 }
453
454 let port = webhook_port(&uri);
456
457 let addr = format!("{host_bare}:{port}");
458 let resolved = tokio::net::lookup_host(&addr).await.map_err(|e| {
459 A2aError::invalid_params(format!(
460 "webhook URL hostname could not be resolved: {host_bare}: {e}"
461 ))
462 })?;
463
464 let mut pinned: Option<SocketAddr> = None;
465 for socket_addr in resolved {
466 let ip = socket_addr.ip();
467 if is_private_ip(ip) {
468 return Err(A2aError::invalid_params(format!(
469 "webhook URL hostname {host_bare} resolves to private/loopback address: {ip}"
470 )));
471 }
472 if pinned.is_none() {
473 pinned = Some(socket_addr);
474 }
475 }
476
477 pinned
478 .ok_or_else(|| {
479 A2aError::invalid_params(format!(
480 "webhook URL hostname {host_bare} did not resolve to any addresses"
481 ))
482 })
483 .map(Some)
484}
485
486fn rewrite_uri_with_pinned_addr(url: &str, pinned: SocketAddr) -> A2aResult<hyper::Uri> {
496 let uri: hyper::Uri = url
497 .parse()
498 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
499
500 let scheme = uri
501 .scheme_str()
502 .ok_or_else(|| A2aError::invalid_params("webhook URL missing scheme"))?;
503
504 let host_str = match pinned.ip() {
506 IpAddr::V4(v4) => v4.to_string(),
507 IpAddr::V6(v6) => format!("[{v6}]"),
508 };
509
510 let path_and_query = uri
511 .path_and_query()
512 .map_or_else(|| "/".to_string(), std::string::ToString::to_string);
513
514 let rewritten = format!(
515 "{scheme}://{host_str}:{port}{path_and_query}",
516 port = pinned.port()
517 );
518
519 rewritten
520 .parse()
521 .map_err(|e| A2aError::invalid_params(format!("could not rewrite webhook URL: {e}")))
522}
523
524fn host_header_from_url(url: &str) -> A2aResult<String> {
530 let uri: hyper::Uri = url
531 .parse()
532 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
533 let host = uri
534 .host()
535 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
536 Ok(uri
537 .port_u16()
538 .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")))
539}
540
541const fn pin_target(is_https: bool, pinned_addr: Option<SocketAddr>) -> Option<SocketAddr> {
551 match pinned_addr {
552 Some(addr) if !is_https => Some(addr),
553 _ => None,
554 }
555}
556
557fn validate_header_value(value: &str, name: &str) -> A2aResult<()> {
559 if value.contains('\r') || value.contains('\n') {
560 return Err(A2aError::invalid_params(format!(
561 "{name} contains invalid characters (CR/LF)"
562 )));
563 }
564 Ok(())
565}
566
567#[allow(clippy::manual_async_fn, clippy::too_many_lines)]
568impl PushSender for HttpPushSender {
569 fn allows_private_urls(&self) -> bool {
570 self.allow_private_urls
571 }
572
573 fn send<'a>(
574 &'a self,
575 url: &'a str,
576 event: &'a StreamResponse,
577 config: &'a TaskPushNotificationConfig,
578 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
579 Box::pin(async move {
580 trace_info!(url, "delivering push notification");
581
582 let is_https = url
583 .split_once("://")
584 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
585
586 #[cfg(not(feature = "tls-rustls"))]
592 if is_https {
593 return Err(A2aError::internal(
594 "this build of HttpPushSender delivers over HTTP only and cannot reach an \
595 https:// webhook; enable the `tls-rustls` feature (on by default in \
596 a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
597 ));
598 }
599
600 let pinned_addr = if self.allow_private_urls {
604 None
605 } else {
606 validate_webhook_url_with_dns(url).await?
607 };
608
609 let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
617 Some(addr) => (
618 Some(rewrite_uri_with_pinned_addr(url, addr)?),
619 Some(host_header_from_url(url)?),
620 ),
621 None => (None, None),
622 };
623
624 if let Some(ref auth) = config.authentication {
626 if let Some(ref credentials) = auth.credentials {
627 validate_header_value(credentials, "authentication credentials")?;
628 }
629 validate_header_value(&auth.scheme, "authentication scheme")?;
630 }
631 if let Some(ref token) = config.token {
632 validate_header_value(token, "notification token")?;
633 }
634
635 let body_bytes: Bytes = serde_json::to_vec(event)
636 .map(Bytes::from)
637 .map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
638
639 let mut last_err = String::new();
640
641 for attempt in 0..self.retry_policy.max_attempts {
642 let mut builder = hyper::Request::builder()
643 .method(hyper::Method::POST)
644 .header("content-type", "application/json");
645
646 if let Some(uri) = pinned_uri.as_ref() {
647 builder = builder.uri(uri.clone());
648 if let Some(host) = pinned_host_header.as_deref() {
649 builder = builder.header("host", host);
650 }
651 } else {
652 builder = builder.uri(url);
653 }
654
655 if let Some(ref auth) = config.authentication {
662 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
663 Some("Bearer")
664 } else if auth.scheme.eq_ignore_ascii_case("basic") {
665 Some("Basic")
666 } else {
667 None
668 };
669 match (canonical_scheme, auth.credentials.as_deref()) {
670 (Some(prefix), Some(credentials)) => {
671 builder =
672 builder.header("authorization", format!("{prefix} {credentials}"));
673 }
674 (Some(_), None) => {
675 trace_warn!(
676 scheme = auth.scheme.as_str(),
677 "authentication scheme has no credentials; no auth header set"
678 );
679 }
680 (None, _) => {
681 trace_warn!(
682 scheme = auth.scheme.as_str(),
683 "unknown authentication scheme; no auth header set"
684 );
685 }
686 }
687 }
688
689 if let Some(ref token) = config.token {
699 builder = builder.header("x-a2a-notification-token", token.as_str());
700 }
701
702 let req = builder
703 .body(Full::new(body_bytes.clone()))
704 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
705
706 let request_result =
707 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
708
709 match request_result {
710 Ok(Ok(resp)) if resp.status().is_success() => {
711 trace_debug!(url, "push notification delivered");
712 return Ok(());
713 }
714 Ok(Ok(resp)) => {
715 let status = resp.status();
716 let retryable = status.is_server_error()
722 || status == hyper::StatusCode::REQUEST_TIMEOUT
723 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
724 if !retryable {
725 trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
726 return Err(A2aError::internal(format!(
727 "push notification got non-retryable HTTP {status}"
728 )));
729 }
730 last_err = format!("push notification got HTTP {status}");
731 trace_warn!(url, attempt, status = %status, "push delivery failed");
732 }
733 Ok(Err(e)) => {
734 last_err = format!("push notification failed: {e}");
735 trace_warn!(url, attempt, error = %e, "push delivery error");
736 }
737 Err(_) => {
738 last_err = format!(
739 "push notification timed out after {}s",
740 self.request_timeout.as_secs()
741 );
742 trace_warn!(url, attempt, "push delivery timed out");
743 }
744 }
745
746 if attempt < self.retry_policy.max_attempts - 1 {
748 let delay = self
749 .retry_policy
750 .backoff
751 .get(attempt)
752 .or_else(|| self.retry_policy.backoff.last());
753 if let Some(delay) = delay {
754 tokio::time::sleep(*delay).await;
755 }
756 }
757 }
758
759 Err(A2aError::internal(last_err))
760 })
761 }
762}
763
764#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
772 fn push_retry_policy_with_max_attempts() {
773 let policy = PushRetryPolicy::default().with_max_attempts(5);
774 assert_eq!(policy.max_attempts, 5);
775 assert_eq!(policy.backoff.len(), 2);
777 }
778
779 #[test]
781 fn push_retry_policy_with_backoff() {
782 let backoff = vec![
783 std::time::Duration::from_millis(100),
784 std::time::Duration::from_millis(500),
785 std::time::Duration::from_secs(1),
786 ];
787 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
788 assert_eq!(policy.backoff, backoff);
789 assert_eq!(policy.max_attempts, 3);
791 }
792
793 #[test]
795 fn http_push_sender_with_retry_policy() {
796 let policy = PushRetryPolicy::default().with_max_attempts(10);
797 let sender = HttpPushSender::new().with_retry_policy(policy);
798 assert_eq!(sender.retry_policy.max_attempts, 10);
799 }
800
801 #[test]
803 fn rejects_url_without_host() {
804 assert!(validate_webhook_url("http:///path").is_err());
805 }
806
807 #[test]
809 fn http_push_sender_allow_private_urls() {
810 let sender = HttpPushSender::new().allow_private_urls();
811 assert!(sender.allow_private_urls);
812 }
813
814 #[test]
816 fn http_push_sender_default() {
817 let sender = HttpPushSender::default();
818 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
819 assert!(!sender.allow_private_urls);
820 }
821
822 #[test]
824 fn push_retry_policy_default() {
825 let policy = PushRetryPolicy::default();
826 assert_eq!(policy.max_attempts, 3);
827 assert_eq!(policy.backoff.len(), 2);
828 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
829 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
830 }
831
832 #[test]
833 fn rejects_loopback_ipv4() {
834 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
835 }
836
837 #[test]
838 fn rejects_private_10_range() {
839 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
840 }
841
842 #[test]
843 fn rejects_private_172_range() {
844 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
845 }
846
847 #[test]
848 fn rejects_private_192_168_range() {
849 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
850 }
851
852 #[test]
853 fn rejects_link_local() {
854 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
855 }
856
857 #[test]
858 fn rejects_localhost() {
859 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
860 }
861
862 #[test]
863 fn rejects_dot_local() {
864 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
865 }
866
867 #[test]
868 fn rejects_dot_internal() {
869 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
870 }
871
872 #[test]
873 fn rejects_ipv6_loopback() {
874 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
875 }
876
877 #[test]
884 fn rejects_ipv4_mapped_loopback() {
885 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
886 }
887
888 #[test]
889 fn rejects_ipv4_mapped_metadata() {
890 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
891 }
892
893 #[test]
894 fn rejects_ipv4_mapped_private() {
895 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
896 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
897 }
898
899 #[test]
900 fn rejects_ipv4_compatible_loopback() {
901 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
903 }
904
905 #[test]
906 fn rejects_nat64_wellknown_prefix_to_private() {
907 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
909 }
910
911 #[test]
912 fn accepts_ipv4_mapped_public() {
913 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
915 }
916
917 #[test]
925 fn is_private_v4_cgnat_boundary() {
926 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
928 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()));
934 }
935
936 #[test]
937 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
938 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
939 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
940
941 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
943 assert_eq!(
944 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
945 v4("169.254.169.254")
946 );
947 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
950 assert_eq!(embedded_ipv4(v6("::")), None);
951 assert_eq!(embedded_ipv4(v6("::1")), None);
952 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
954 }
955
956 #[test]
957 fn accepts_public_url() {
958 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
959 }
960
961 #[test]
962 fn accepts_public_ip() {
963 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
964 }
965
966 #[test]
967 fn rejects_header_with_crlf() {
968 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
969 }
970
971 #[test]
972 fn rejects_header_with_cr() {
973 assert!(validate_header_value("token\rvalue", "test").is_err());
974 }
975
976 #[test]
977 fn rejects_header_with_lf() {
978 assert!(validate_header_value("token\nvalue", "test").is_err());
979 }
980
981 #[test]
982 fn accepts_clean_header_value() {
983 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
984 }
985
986 #[test]
987 fn rejects_url_without_scheme() {
988 assert!(validate_webhook_url("example.com/webhook").is_err());
989 }
990
991 #[test]
992 fn rejects_ftp_scheme() {
993 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
994 }
995
996 #[test]
997 fn rejects_file_scheme() {
998 assert!(validate_webhook_url("file:///etc/passwd").is_err());
999 }
1000
1001 #[test]
1002 fn accepts_http_scheme() {
1003 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
1004 }
1005
1006 #[test]
1007 fn rejects_cgnat_range() {
1008 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
1009 }
1010
1011 #[test]
1012 fn rejects_unspecified_ipv4() {
1013 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
1014 }
1015
1016 #[test]
1017 fn rejects_ipv6_unique_local() {
1018 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1019 }
1020
1021 #[test]
1022 fn rejects_ipv6_link_local() {
1023 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1024 }
1025
1026 #[tokio::test]
1029 async fn dns_rejects_loopback_ip_literal() {
1030 let result = validate_webhook_url_with_dns("http://127.0.0.1:8080/webhook").await;
1032 assert!(result.is_err(), "loopback IP should be rejected");
1033 }
1034
1035 #[tokio::test]
1036 async fn dns_rejects_private_ip_literal() {
1037 let result = validate_webhook_url_with_dns("http://10.0.0.1/webhook").await;
1038 assert!(result.is_err(), "private IP should be rejected");
1039 }
1040
1041 #[tokio::test]
1042 async fn dns_rejects_localhost_hostname() {
1043 let result = validate_webhook_url_with_dns("http://localhost:8080/webhook").await;
1045 assert!(result.is_err(), "localhost should be rejected");
1046 }
1047
1048 #[tokio::test]
1049 async fn dns_rejects_invalid_scheme() {
1050 let result = validate_webhook_url_with_dns("ftp://example.com/webhook").await;
1051 assert!(result.is_err(), "ftp scheme should be rejected");
1052 }
1053
1054 #[tokio::test]
1055 async fn dns_rejects_missing_host() {
1056 let result = validate_webhook_url_with_dns("http:///path").await;
1057 assert!(result.is_err(), "missing host should be rejected");
1058 }
1059
1060 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1061 async fn dns_rejects_unresolvable_hostname() {
1062 let (tx, rx) = tokio::sync::oneshot::channel();
1065 std::thread::spawn(move || {
1066 let rt = tokio::runtime::Builder::new_current_thread()
1067 .enable_all()
1068 .build()
1069 .unwrap();
1070 let result = rt.block_on(validate_webhook_url_with_dns(
1071 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1072 ));
1073 let _ = tx.send(result);
1074 });
1075 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1076 Ok(Ok(result)) => {
1077 assert!(result.is_err(), "unresolvable hostname should be rejected");
1078 }
1079 Ok(Err(_)) => panic!("sender dropped without sending"),
1080 Err(_elapsed) => {
1081 }
1083 }
1084 }
1085
1086 #[tokio::test]
1087 async fn dns_accepts_ip_literal_public() {
1088 let result = validate_webhook_url_with_dns("https://203.0.113.1/webhook").await;
1091 assert!(
1092 matches!(result, Ok(None)),
1093 "public IP literal should be accepted with no pinning (got {result:?})",
1094 );
1095 }
1096
1097 #[test]
1100 fn rewrite_uri_preserves_scheme_path_and_query() {
1101 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1102 let rewritten =
1103 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1104 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1105 }
1106
1107 #[test]
1108 fn rewrite_uri_uses_ipv6_brackets() {
1109 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1110 let rewritten =
1111 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1112 assert!(
1114 rewritten.to_string().contains("[2001:db8::1]:443"),
1115 "IPv6 literal should be bracketed: {rewritten}",
1116 );
1117 }
1118
1119 #[test]
1120 fn rewrite_uri_default_path_when_missing() {
1121 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1122 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1123 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1124 }
1125
1126 #[test]
1127 fn host_header_includes_port_when_present() {
1128 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1129 assert_eq!(host, "example.com:8080");
1130 }
1131
1132 #[test]
1133 fn host_header_omits_default_port() {
1134 let host = host_header_from_url("https://example.com/webhook").unwrap();
1135 assert_eq!(host, "example.com");
1136 }
1137
1138 #[test]
1139 fn host_header_from_url_rejects_missing_host() {
1140 let result = host_header_from_url("http:///path");
1141 assert!(result.is_err());
1142 }
1143
1144 #[test]
1145 fn pin_target_pins_http_but_not_https() {
1146 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1147 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1149 assert_eq!(pin_target(true, Some(addr)), None);
1152 assert_eq!(pin_target(false, None), None);
1154 assert_eq!(pin_target(true, None), None);
1155 }
1156
1157 fn dummy_event() -> StreamResponse {
1160 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1161 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1162 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1163 task_id: TaskId::new("t1"),
1164 context_id: ContextId::new("c1"),
1165 status: TaskStatus::with_timestamp(TaskState::Working),
1166 metadata: None,
1167 })
1168 }
1169
1170 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1171 TaskPushNotificationConfig {
1172 tenant: None,
1173 id: Some("cfg".to_owned()),
1174 task_id: Some("t1".to_owned()),
1175 url: url.to_owned(),
1176 token: None,
1177 authentication: None,
1178 }
1179 }
1180
1181 #[cfg(not(feature = "tls-rustls"))]
1184 #[tokio::test]
1185 async fn https_without_tls_feature_fails_fast() {
1186 let sender = HttpPushSender::new();
1187 let event = dummy_event();
1188 let config = dummy_config("https://example.com/webhook");
1189 let err = sender
1190 .send(&config.url, &event, &config)
1191 .await
1192 .expect_err("https must fail fast without the tls-rustls feature");
1193 assert!(
1194 err.to_string().contains("HTTP only"),
1195 "expected the HTTP-only error, got: {err}"
1196 );
1197 }
1198
1199 #[cfg(feature = "tls-rustls")]
1203 #[tokio::test]
1204 async fn https_with_tls_feature_still_enforces_ssrf() {
1205 let sender = HttpPushSender::new();
1206 let event = dummy_event();
1207 let config = dummy_config("https://127.0.0.1:8443/webhook");
1208 let err = sender
1209 .send(&config.url, &event, &config)
1210 .await
1211 .expect_err("https to a loopback address must be rejected by SSRF");
1212 let msg = err.to_string();
1213 assert!(
1214 msg.contains("private/loopback") || msg.contains("loopback"),
1215 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1216 );
1217 }
1218}
1219
1220#[cfg(test)]
1221mod port_tests {
1222 use super::webhook_port;
1223
1224 #[test]
1228 fn scheme_defaults_are_not_swapped() {
1229 let port = |u: &str| webhook_port(&u.parse::<hyper::Uri>().expect("uri"));
1230
1231 assert_eq!(
1232 port("https://example.com/hook"),
1233 443,
1234 "https defaults to 443"
1235 );
1236 assert_eq!(port("http://example.com/hook"), 80, "http defaults to 80");
1237 assert_eq!(port("https://example.com:8443/hook"), 8443);
1240 assert_eq!(port("http://example.com:8080/hook"), 8080);
1241 }
1242}