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::Client;
27use hyper_util::client::legacy::connect::HttpConnector;
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 fn max_delivery_duration(&self) -> Option<std::time::Duration> {
134 None
135 }
136}
137
138const DEFAULT_PUSH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
140
141const DEFAULT_DNS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
144
145#[derive(Debug, Clone)]
166#[non_exhaustive]
167pub struct PushRetryPolicy {
168 pub max_attempts: usize,
170 pub backoff: Vec<std::time::Duration>,
175}
176
177impl Default for PushRetryPolicy {
178 fn default() -> Self {
179 Self {
180 max_attempts: 3,
181 backoff: vec![
182 std::time::Duration::from_secs(1),
183 std::time::Duration::from_secs(2),
184 ],
185 }
186 }
187}
188
189impl PushRetryPolicy {
190 #[must_use]
192 pub const fn with_max_attempts(mut self, max: usize) -> Self {
193 self.max_attempts = max;
194 self
195 }
196
197 #[must_use]
199 pub fn with_backoff(mut self, backoff: Vec<std::time::Duration>) -> Self {
200 self.backoff = backoff;
201 self
202 }
203}
204
205#[derive(Debug)]
242pub struct HttpPushSender {
243 client: PushHttpClient,
244 request_timeout: std::time::Duration,
245 retry_policy: PushRetryPolicy,
246 dns_timeout: std::time::Duration,
248 allow_private_urls: bool,
250}
251
252impl Default for HttpPushSender {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258impl HttpPushSender {
259 #[must_use]
262 pub fn new() -> Self {
263 Self::with_timeout(DEFAULT_PUSH_REQUEST_TIMEOUT)
264 }
265
266 #[must_use]
268 pub fn with_timeout(request_timeout: std::time::Duration) -> Self {
269 let client = build_push_http_client();
270 Self {
271 client,
272 request_timeout,
273 retry_policy: PushRetryPolicy::default(),
274 dns_timeout: DEFAULT_DNS_LOOKUP_TIMEOUT,
275 allow_private_urls: false,
276 }
277 }
278
279 #[cfg(feature = "tls-rustls")]
290 #[must_use]
291 pub fn with_tls_config(tls_config: rustls::ClientConfig) -> Self {
292 Self {
293 client: build_push_https_client(tls_config),
294 request_timeout: DEFAULT_PUSH_REQUEST_TIMEOUT,
295 retry_policy: PushRetryPolicy::default(),
296 dns_timeout: DEFAULT_DNS_LOOKUP_TIMEOUT,
297 allow_private_urls: false,
298 }
299 }
300
301 #[must_use]
312 pub const fn with_dns_timeout(mut self, timeout: std::time::Duration) -> Self {
313 self.dns_timeout = timeout;
314 self
315 }
316
317 #[must_use]
319 pub fn with_retry_policy(mut self, policy: PushRetryPolicy) -> Self {
320 self.retry_policy = policy;
321 self
322 }
323
324 #[must_use]
329 pub const fn allow_private_urls(mut self) -> Self {
330 self.allow_private_urls = true;
331 self
332 }
333}
334
335#[allow(clippy::missing_const_for_fn)] fn is_private_v4(v4: Ipv4Addr) -> bool {
339 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) }
345
346fn parse_numeric_ipv4(host: &str) -> Option<Ipv4Addr> {
360 let parts: Vec<&str> = host.split('.').collect();
364 if parts.len() > 4 {
365 return None;
366 }
367 let mut vals: Vec<u64> = Vec::with_capacity(parts.len());
368 for p in &parts {
369 vals.push(parse_c_integer(p)?);
370 }
371 let (last, head) = vals.split_last()?;
383 let mut octets = [0_u8; 4];
384 for (slot, part) in octets.iter_mut().zip(head) {
385 *slot = u8::try_from(*part).ok()?;
386 }
387 let tail_bytes = 4 - head.len();
388 if *last > (1_u64 << (8 * tail_bytes)) - 1 {
389 return None;
390 }
391 let last = u32::try_from(*last).ok()?;
392 octets[head.len()..].copy_from_slice(&last.to_be_bytes()[head.len()..]);
393 Some(Ipv4Addr::from(octets))
394}
395
396fn parse_c_integer(s: &str) -> Option<u64> {
400 if s.is_empty() {
401 return None;
402 }
403 if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
404 if hex.is_empty() {
405 return None;
406 }
407 u64::from_str_radix(hex, 16).ok()
408 } else if s.len() > 1 && s.starts_with('0') {
409 u64::from_str_radix(&s[1..], 8).ok()
410 } else {
411 s.parse::<u64>().ok()
412 }
413}
414
415fn embedded_ipv4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
424 if let Some(v4) = v6.to_ipv4_mapped() {
425 return Some(v4);
426 }
427 let v4_from = |g: u16, h: u16| {
428 let [a, b] = g.to_be_bytes();
429 let [c, d] = h.to_be_bytes();
430 Ipv4Addr::new(a, b, c, d)
431 };
432 match v6.segments() {
433 [0x0064, 0xff9b, 0, 0, 0, 0, g, h] => Some(v4_from(g, h)),
435 [0, 0, 0, 0, 0, 0, g, h] if !(g == 0 && (h == 0 || h == 1)) => Some(v4_from(g, h)),
437 _ => None,
438 }
439}
440
441#[allow(clippy::missing_const_for_fn)] fn is_private_ip(ip: IpAddr) -> bool {
444 match ip {
445 IpAddr::V4(v4) => is_private_v4(v4),
446 IpAddr::V6(v6) => {
447 if let Some(v4) = embedded_ipv4(v6) {
451 return is_private_v4(v4);
452 }
453 v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00
457 || (v6.segments()[0] & 0xffc0) == 0xfe80
459 }
460 }
461}
462
463#[allow(clippy::case_sensitive_file_extension_comparisons)] pub(crate) fn validate_webhook_url(url: &str) -> A2aResult<()> {
469 let uri: hyper::Uri = url
471 .parse()
472 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
473
474 match uri.scheme_str() {
476 Some("http" | "https") => {}
477 Some(other) => {
478 return Err(A2aError::invalid_params(format!(
479 "webhook URL has unsupported scheme: {other} (expected http or https)"
480 )));
481 }
482 None => {
483 return Err(A2aError::invalid_params(
484 "webhook URL missing scheme (expected http:// or https://)",
485 ));
486 }
487 }
488
489 let host = uri
490 .host()
491 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
492
493 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
495
496 if let Ok(ip) = host_bare.parse::<IpAddr>() {
498 if is_private_ip(ip) {
499 return Err(A2aError::invalid_params(format!(
500 "webhook URL targets private/loopback address: {host}"
501 )));
502 }
503 } else if let Some(v4) = parse_numeric_ipv4(host_bare) {
504 if is_private_v4(v4) {
511 return Err(A2aError::invalid_params(format!(
512 "webhook URL targets private/loopback address: {host} ({v4})"
513 )));
514 }
515 }
516
517 let host_lower = host.to_ascii_lowercase();
519 if host_lower == "localhost"
520 || host_lower.ends_with(".local")
521 || host_lower.ends_with(".internal")
522 {
523 return Err(A2aError::invalid_params(format!(
524 "webhook URL targets local/internal hostname: {host}"
525 )));
526 }
527
528 Ok(())
529}
530
531fn webhook_port(uri: &hyper::Uri) -> u16 {
560 if let Some(explicit) = uri.port_u16() {
561 return explicit;
562 }
563 if uri.scheme_str() == Some("https") {
564 443
565 } else {
566 80
567 }
568}
569
570pub(crate) async fn validate_webhook_url_with_dns(
571 url: &str,
572 dns_timeout: std::time::Duration,
573) -> A2aResult<Option<SocketAddr>> {
574 validate_webhook_url_with_resolver(url, dns_timeout, |addr| async move {
575 tokio::net::lookup_host(addr).await.map(Iterator::collect)
576 })
577 .await
578}
579
580async fn validate_webhook_url_with_resolver<F, Fut>(
585 url: &str,
586 dns_timeout: std::time::Duration,
587 resolve: F,
588) -> A2aResult<Option<SocketAddr>>
589where
590 F: FnOnce(String) -> Fut,
591 Fut: std::future::Future<Output = std::io::Result<Vec<SocketAddr>>>,
592{
593 validate_webhook_url(url)?;
595
596 let uri: hyper::Uri = url
598 .parse()
599 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
600
601 let host = uri
602 .host()
603 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
604
605 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
607
608 if host_bare.parse::<IpAddr>().is_ok() {
611 return Ok(None);
612 }
613
614 let port = webhook_port(&uri);
616
617 let addr = format!("{host_bare}:{port}");
618 let resolved = tokio::time::timeout(dns_timeout, resolve(addr))
619 .await
620 .map_err(|_elapsed| {
621 A2aError::invalid_params(format!(
622 "webhook URL hostname resolution timed out after {dns_timeout:?}: {host_bare}"
623 ))
624 })?
625 .map_err(|e| {
626 A2aError::invalid_params(format!(
627 "webhook URL hostname could not be resolved: {host_bare}: {e}"
628 ))
629 })?;
630
631 let mut pinned: Option<SocketAddr> = None;
632 for socket_addr in resolved {
633 let ip = socket_addr.ip();
634 if is_private_ip(ip) {
635 return Err(A2aError::invalid_params(format!(
636 "webhook URL hostname {host_bare} resolves to private/loopback address: {ip}"
637 )));
638 }
639 if pinned.is_none() {
640 pinned = Some(socket_addr);
641 }
642 }
643
644 pinned
645 .ok_or_else(|| {
646 A2aError::invalid_params(format!(
647 "webhook URL hostname {host_bare} did not resolve to any addresses"
648 ))
649 })
650 .map(Some)
651}
652
653fn rewrite_uri_with_pinned_addr(url: &str, pinned: SocketAddr) -> A2aResult<hyper::Uri> {
663 let uri: hyper::Uri = url
664 .parse()
665 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
666
667 let scheme = uri
668 .scheme_str()
669 .ok_or_else(|| A2aError::invalid_params("webhook URL missing scheme"))?;
670
671 let host_str = match pinned.ip() {
673 IpAddr::V4(v4) => v4.to_string(),
674 IpAddr::V6(v6) => format!("[{v6}]"),
675 };
676
677 let path_and_query = uri
678 .path_and_query()
679 .map_or_else(|| "/".to_string(), std::string::ToString::to_string);
680
681 let rewritten = format!(
682 "{scheme}://{host_str}:{port}{path_and_query}",
683 port = pinned.port()
684 );
685
686 rewritten
687 .parse()
688 .map_err(|e| A2aError::invalid_params(format!("could not rewrite webhook URL: {e}")))
689}
690
691fn host_header_from_url(url: &str) -> A2aResult<String> {
697 let uri: hyper::Uri = url
698 .parse()
699 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
700 let host = uri
701 .host()
702 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
703 Ok(uri
704 .port_u16()
705 .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")))
706}
707
708const fn pin_target(is_https: bool, pinned_addr: Option<SocketAddr>) -> Option<SocketAddr> {
718 match pinned_addr {
719 Some(addr) if !is_https => Some(addr),
720 _ => None,
721 }
722}
723
724fn validate_header_value(value: &str, name: &str) -> A2aResult<()> {
726 if value.contains('\r') || value.contains('\n') {
727 return Err(A2aError::invalid_params(format!(
728 "{name} contains invalid characters (CR/LF)"
729 )));
730 }
731 Ok(())
732}
733
734#[allow(clippy::manual_async_fn, clippy::too_many_lines)]
735impl PushSender for HttpPushSender {
736 fn max_delivery_duration(&self) -> Option<std::time::Duration> {
742 let attempts = self.retry_policy.max_attempts;
743 if attempts == 0 {
744 return Some(std::time::Duration::ZERO);
745 }
746 let mut total = self
749 .request_timeout
750 .saturating_mul(u32::try_from(attempts).unwrap_or(u32::MAX))
751 .saturating_add(self.dns_timeout);
752 for attempt in 0..attempts.saturating_sub(1) {
753 if let Some(delay) = self
754 .retry_policy
755 .backoff
756 .get(attempt)
757 .or_else(|| self.retry_policy.backoff.last())
758 {
759 total = total.saturating_add(*delay);
760 }
761 }
762 Some(total)
763 }
764
765 fn allows_private_urls(&self) -> bool {
766 self.allow_private_urls
767 }
768
769 fn send<'a>(
770 &'a self,
771 url: &'a str,
772 event: &'a StreamResponse,
773 config: &'a TaskPushNotificationConfig,
774 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
775 Box::pin(async move {
776 trace_info!(url, "delivering push notification");
777
778 let is_https = url
779 .split_once("://")
780 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
781
782 #[cfg(not(feature = "tls-rustls"))]
788 if is_https {
789 return Err(A2aError::internal(
790 "this build of HttpPushSender delivers over HTTP only and cannot reach an \
791 https:// webhook; enable the `tls-rustls` feature (on by default in \
792 a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
793 ));
794 }
795
796 let pinned_addr = if self.allow_private_urls {
800 None
801 } else {
802 validate_webhook_url_with_dns(url, self.dns_timeout).await?
803 };
804
805 let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
813 Some(addr) => (
814 Some(rewrite_uri_with_pinned_addr(url, addr)?),
815 Some(host_header_from_url(url)?),
816 ),
817 None => (None, None),
818 };
819
820 if let Some(ref auth) = config.authentication {
822 if let Some(ref credentials) = auth.credentials {
823 validate_header_value(credentials, "authentication credentials")?;
824 }
825 validate_header_value(&auth.scheme, "authentication scheme")?;
826 }
827 if let Some(ref token) = config.token {
828 validate_header_value(token, "notification token")?;
829 }
830
831 let body_bytes: Bytes = serde_json::to_vec(event)
832 .map(Bytes::from)
833 .map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
834
835 let mut last_err = String::new();
836
837 for attempt in 0..self.retry_policy.max_attempts {
838 let mut builder = hyper::Request::builder()
850 .method(hyper::Method::POST)
851 .header("content-type", a2a_protocol_types::A2A_CONTENT_TYPE);
852
853 if let Some(uri) = pinned_uri.as_ref() {
854 builder = builder.uri(uri.clone());
855 if let Some(host) = pinned_host_header.as_deref() {
856 builder = builder.header("host", host);
857 }
858 } else {
859 builder = builder.uri(url);
860 }
861
862 if let Some(ref auth) = config.authentication {
869 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
870 Some("Bearer")
871 } else if auth.scheme.eq_ignore_ascii_case("basic") {
872 Some("Basic")
873 } else {
874 None
875 };
876 match (canonical_scheme, auth.credentials.as_deref()) {
877 (Some(prefix), Some(credentials)) => {
878 builder =
879 builder.header("authorization", format!("{prefix} {credentials}"));
880 }
881 (Some(_), None) => {
882 trace_warn!(
883 scheme = auth.scheme.as_str(),
884 "authentication scheme has no credentials; no auth header set"
885 );
886 }
887 (None, _) => {
888 trace_warn!(
889 scheme = auth.scheme.as_str(),
890 "unknown authentication scheme; no auth header set"
891 );
892 }
893 }
894 }
895
896 if let Some(ref token) = config.token {
906 builder = builder.header("x-a2a-notification-token", token.as_str());
907 }
908
909 let req = builder
910 .body(Full::new(body_bytes.clone()))
911 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
912
913 let request_result =
914 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
915
916 match request_result {
917 Ok(Ok(resp)) if resp.status().is_success() => {
918 trace_debug!(url, "push notification delivered");
919 return Ok(());
920 }
921 Ok(Ok(resp)) => {
922 let status = resp.status();
923 let retryable = status.is_server_error()
929 || status == hyper::StatusCode::REQUEST_TIMEOUT
930 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
931 if !retryable {
932 trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
933 return Err(A2aError::internal(format!(
934 "push notification got non-retryable HTTP {status}"
935 )));
936 }
937 last_err = format!("push notification got HTTP {status}");
938 trace_warn!(url, attempt, status = %status, "push delivery failed");
939 }
940 Ok(Err(e)) => {
941 last_err = format!("push notification failed: {e}");
942 trace_warn!(url, attempt, error = %e, "push delivery error");
943 }
944 Err(_) => {
945 last_err = format!(
946 "push notification timed out after {}s",
947 self.request_timeout.as_secs()
948 );
949 trace_warn!(url, attempt, "push delivery timed out");
950 }
951 }
952
953 if attempt < self.retry_policy.max_attempts - 1 {
955 let delay = self
956 .retry_policy
957 .backoff
958 .get(attempt)
959 .or_else(|| self.retry_policy.backoff.last());
960 if let Some(delay) = delay {
961 tokio::time::sleep(*delay).await;
962 }
963 }
964 }
965
966 Err(A2aError::internal(last_err))
967 })
968 }
969}
970
971#[cfg(test)]
974mod tests {
975 use super::*;
976
977 #[test]
981 fn a_sender_that_does_not_report_a_schedule_reports_none() {
982 struct SaysNothing;
983 impl PushSender for SaysNothing {
984 fn send<'a>(
985 &'a self,
986 _url: &'a str,
987 _event: &'a StreamResponse,
988 _config: &'a TaskPushNotificationConfig,
989 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
990 Box::pin(async { Ok(()) })
991 }
992 }
993 assert_eq!(SaysNothing.max_delivery_duration(), None);
994 }
995
996 #[test]
998 fn push_retry_policy_with_max_attempts() {
999 let policy = PushRetryPolicy::default().with_max_attempts(5);
1000 assert_eq!(policy.max_attempts, 5);
1001 assert_eq!(policy.backoff.len(), 2);
1003 }
1004
1005 #[test]
1007 fn push_retry_policy_with_backoff() {
1008 let backoff = vec![
1009 std::time::Duration::from_millis(100),
1010 std::time::Duration::from_millis(500),
1011 std::time::Duration::from_secs(1),
1012 ];
1013 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
1014 assert_eq!(policy.backoff, backoff);
1015 assert_eq!(policy.max_attempts, 3);
1017 }
1018
1019 #[test]
1021 fn http_push_sender_with_retry_policy() {
1022 let policy = PushRetryPolicy::default().with_max_attempts(10);
1023 let sender = HttpPushSender::new().with_retry_policy(policy);
1024 assert_eq!(sender.retry_policy.max_attempts, 10);
1025 }
1026
1027 #[test]
1029 fn rejects_url_without_host() {
1030 assert!(validate_webhook_url("http:///path").is_err());
1031 }
1032
1033 #[test]
1035 fn http_push_sender_allow_private_urls() {
1036 let sender = HttpPushSender::new().allow_private_urls();
1037 assert!(sender.allow_private_urls);
1038 }
1039
1040 #[test]
1042 fn http_push_sender_default() {
1043 let sender = HttpPushSender::default();
1044 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
1045 assert!(!sender.allow_private_urls);
1046 }
1047
1048 #[test]
1050 fn push_retry_policy_default() {
1051 let policy = PushRetryPolicy::default();
1052 assert_eq!(policy.max_attempts, 3);
1053 assert_eq!(policy.backoff.len(), 2);
1054 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
1055 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
1056 }
1057
1058 #[test]
1059 fn rejects_loopback_ipv4() {
1060 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
1061 }
1062
1063 #[test]
1064 fn rejects_private_10_range() {
1065 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
1066 }
1067
1068 #[test]
1069 fn rejects_private_172_range() {
1070 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
1071 }
1072
1073 #[test]
1074 fn rejects_private_192_168_range() {
1075 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
1076 }
1077
1078 #[test]
1079 fn rejects_link_local() {
1080 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
1081 }
1082
1083 #[test]
1084 fn parse_numeric_ipv4_matches_the_resolver() {
1085 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
1086 assert_eq!(
1088 parse_numeric_ipv4("2852039166"),
1089 Some(v4("169.254.169.254"))
1090 ); assert_eq!(
1092 parse_numeric_ipv4("0xA9FEA9FE"),
1093 Some(v4("169.254.169.254"))
1094 ); assert_eq!(
1096 parse_numeric_ipv4("0xa9fea9fe"),
1097 Some(v4("169.254.169.254"))
1098 ); assert_eq!(
1100 parse_numeric_ipv4("0251.0376.0251.0376"),
1101 Some(v4("169.254.169.254")) );
1103 assert_eq!(parse_numeric_ipv4("2130706433"), Some(v4("127.0.0.1"))); assert_eq!(parse_numeric_ipv4("127.1"), Some(v4("127.0.0.1"))); assert_eq!(parse_numeric_ipv4("0x7f.0.0.1"), Some(v4("127.0.0.1"))); assert_eq!(parse_numeric_ipv4("134744072"), Some(v4("8.8.8.8")));
1110 assert_eq!(parse_numeric_ipv4("example.com"), None);
1112 assert_eq!(parse_numeric_ipv4("printer.local"), None);
1113 assert_eq!(parse_numeric_ipv4("256.1.1.1"), None); assert_eq!(parse_numeric_ipv4("1.2.3.4.5"), None); assert_eq!(parse_numeric_ipv4("99999999999999999999"), None); assert_eq!(parse_numeric_ipv4("0x"), None); assert_eq!(parse_numeric_ipv4("1e10"), None); }
1119
1120 #[test]
1129 fn parse_numeric_ipv4_packs_every_part_count_exactly() {
1130 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
1131
1132 assert_eq!(parse_numeric_ipv4("1"), Some(v4("0.0.0.1")));
1134 assert_eq!(parse_numeric_ipv4("1.2"), Some(v4("1.0.0.2")));
1135 assert_eq!(parse_numeric_ipv4("1.2.3"), Some(v4("1.2.0.3")));
1136 assert_eq!(parse_numeric_ipv4("1.2.3.4"), Some(v4("1.2.3.4")));
1137 assert_eq!(parse_numeric_ipv4("0177.0.0.01"), Some(v4("127.0.0.1")));
1138
1139 assert_eq!(
1142 parse_numeric_ipv4("4294967295"),
1143 Some(v4("255.255.255.255"))
1144 );
1145 assert_eq!(parse_numeric_ipv4("4294967296"), None);
1146 assert_eq!(parse_numeric_ipv4("1.16777215"), Some(v4("1.255.255.255")));
1147 assert_eq!(parse_numeric_ipv4("1.16777216"), None);
1148 assert_eq!(parse_numeric_ipv4("1.2.65535"), Some(v4("1.2.255.255")));
1149 assert_eq!(parse_numeric_ipv4("1.2.65536"), None);
1150 assert_eq!(parse_numeric_ipv4("1.2.3.255"), Some(v4("1.2.3.255")));
1151 assert_eq!(parse_numeric_ipv4("1.2.3.256"), None);
1152
1153 assert_eq!(parse_numeric_ipv4("255.1"), Some(v4("255.0.0.1")));
1155 assert_eq!(parse_numeric_ipv4("256.1"), None);
1156 assert_eq!(parse_numeric_ipv4("1.255.1"), Some(v4("1.255.0.1")));
1157 assert_eq!(parse_numeric_ipv4("1.256.1"), None);
1158 assert_eq!(parse_numeric_ipv4("1.2.255.1"), Some(v4("1.2.255.1")));
1159 assert_eq!(parse_numeric_ipv4("1.2.256.1"), None);
1160
1161 assert_eq!(parse_numeric_ipv4("1.2.3.4.5"), None);
1164 assert_eq!(parse_numeric_ipv4("1.2.3.4.5.6"), None);
1165
1166 assert_eq!(parse_numeric_ipv4(""), None);
1168 assert_eq!(parse_numeric_ipv4("."), None);
1169 assert_eq!(parse_numeric_ipv4("1..2"), None);
1170 assert_eq!(parse_numeric_ipv4("1.2."), None);
1171 assert_eq!(parse_numeric_ipv4("-1"), None);
1172 assert_eq!(parse_numeric_ipv4("1.-1"), None);
1173 }
1174
1175 #[test]
1176 fn rejects_decimal_integer_metadata() {
1177 assert!(validate_webhook_url("http://2852039166/latest/meta-data").is_err());
1180 }
1181
1182 #[test]
1183 fn rejects_hex_integer_metadata() {
1184 assert!(validate_webhook_url("http://0xA9FEA9FE/latest").is_err());
1185 }
1186
1187 #[test]
1188 fn rejects_octal_dotted_metadata() {
1189 assert!(validate_webhook_url("http://0251.0376.0251.0376/latest").is_err());
1190 }
1191
1192 #[test]
1193 fn rejects_packed_short_form_loopback() {
1194 assert!(validate_webhook_url("http://2130706433/webhook").is_err()); assert!(validate_webhook_url("http://127.1/webhook").is_err()); assert!(validate_webhook_url("http://0x7f.0.0.1/webhook").is_err());
1197 }
1198
1199 #[test]
1200 fn accepts_public_numeric_ip() {
1201 assert!(validate_webhook_url("http://134744072/webhook").is_ok());
1204 }
1205
1206 #[test]
1207 fn max_delivery_duration_matches_the_retry_loop_it_describes() {
1208 use std::time::Duration;
1209
1210 let sender = HttpPushSender::new();
1212 assert_eq!(
1213 sender.max_delivery_duration(),
1214 Some(Duration::from_secs(5 + 30 + 1 + 30 + 2 + 30)),
1215 "the default schedule is 98 seconds: 5 of DNS, then 93 of requests and backoff"
1216 );
1217
1218 let one = HttpPushSender::with_timeout(Duration::from_secs(7))
1220 .with_retry_policy(PushRetryPolicy::default().with_max_attempts(1))
1221 .with_dns_timeout(Duration::from_secs(1));
1222 assert_eq!(
1223 one.max_delivery_duration(),
1224 Some(Duration::from_secs(1 + 7))
1225 );
1226
1227 let many = HttpPushSender::with_timeout(Duration::from_secs(1))
1230 .with_retry_policy(
1231 PushRetryPolicy::default().with_max_attempts(5), )
1233 .with_dns_timeout(Duration::ZERO);
1234 assert_eq!(
1235 many.max_delivery_duration(),
1236 Some(Duration::from_secs(5 + 1 + 2 + 2 + 2)),
1237 "backoff falls back to its last entry, as `send` does"
1238 );
1239 }
1240
1241 #[test]
1248 fn the_default_schedule_does_not_fit_the_default_handler_bound() {
1249 use crate::handler::HandlerLimits;
1250
1251 let wanted = HttpPushSender::new()
1252 .max_delivery_duration()
1253 .expect("HttpPushSender reports its schedule");
1254 let allowed = HandlerLimits::default().push_delivery_timeout;
1255
1256 assert!(
1257 wanted > allowed,
1258 "if these no longer contradict, the fix landed — update this test \
1259 and the arithmetic in HandlerLimits::push_delivery_timeout's docs. \
1260 sender wants {wanted:?}, handler allows {allowed:?}"
1261 );
1262
1263 let policy = PushRetryPolicy::default();
1267 let request = std::time::Duration::from_secs(30);
1268 let mut spent = std::time::Duration::ZERO;
1269 let mut attempts = 0_usize;
1270 for i in 0..policy.max_attempts {
1271 if spent >= allowed {
1272 break;
1273 }
1274 attempts += 1;
1275 spent += request;
1276 if let Some(d) = policy.backoff.get(i).or_else(|| policy.backoff.last()) {
1277 spent += *d;
1278 }
1279 }
1280 assert_eq!(
1281 attempts, 1,
1282 "at the shipped defaults one attempt of {} runs",
1283 policy.max_attempts
1284 );
1285 }
1286
1287 #[test]
1288 fn rejects_localhost() {
1289 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
1290 }
1291
1292 #[test]
1293 fn rejects_dot_local() {
1294 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
1295 }
1296
1297 #[test]
1298 fn rejects_dot_internal() {
1299 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
1300 }
1301
1302 #[test]
1303 fn rejects_ipv6_loopback() {
1304 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
1305 }
1306
1307 #[test]
1314 fn rejects_ipv4_mapped_loopback() {
1315 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
1316 }
1317
1318 #[test]
1319 fn rejects_ipv4_mapped_metadata() {
1320 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
1321 }
1322
1323 #[test]
1324 fn rejects_ipv4_mapped_private() {
1325 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
1326 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
1327 }
1328
1329 #[test]
1330 fn rejects_ipv4_compatible_loopback() {
1331 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
1333 }
1334
1335 #[test]
1336 fn rejects_nat64_wellknown_prefix_to_private() {
1337 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
1339 }
1340
1341 #[test]
1342 fn accepts_ipv4_mapped_public() {
1343 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
1345 }
1346
1347 #[test]
1355 fn is_private_v4_cgnat_boundary() {
1356 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
1358 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()));
1364 }
1365
1366 #[test]
1367 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
1368 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
1369 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
1370
1371 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
1373 assert_eq!(
1374 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
1375 v4("169.254.169.254")
1376 );
1377 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
1380 assert_eq!(embedded_ipv4(v6("::")), None);
1381 assert_eq!(embedded_ipv4(v6("::1")), None);
1382 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
1384 }
1385
1386 #[test]
1387 fn accepts_public_url() {
1388 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
1389 }
1390
1391 #[test]
1392 fn accepts_public_ip() {
1393 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
1394 }
1395
1396 #[test]
1397 fn rejects_header_with_crlf() {
1398 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
1399 }
1400
1401 #[test]
1402 fn rejects_header_with_cr() {
1403 assert!(validate_header_value("token\rvalue", "test").is_err());
1404 }
1405
1406 #[test]
1407 fn rejects_header_with_lf() {
1408 assert!(validate_header_value("token\nvalue", "test").is_err());
1409 }
1410
1411 #[test]
1412 fn accepts_clean_header_value() {
1413 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
1414 }
1415
1416 #[test]
1417 fn rejects_url_without_scheme() {
1418 assert!(validate_webhook_url("example.com/webhook").is_err());
1419 }
1420
1421 #[test]
1422 fn rejects_ftp_scheme() {
1423 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
1424 }
1425
1426 #[test]
1427 fn rejects_file_scheme() {
1428 assert!(validate_webhook_url("file:///etc/passwd").is_err());
1429 }
1430
1431 #[test]
1432 fn accepts_http_scheme() {
1433 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
1434 }
1435
1436 #[test]
1437 fn rejects_cgnat_range() {
1438 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
1439 }
1440
1441 #[test]
1442 fn rejects_unspecified_ipv4() {
1443 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
1444 }
1445
1446 #[test]
1447 fn rejects_ipv6_unique_local() {
1448 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1449 }
1450
1451 #[test]
1452 fn rejects_ipv6_link_local() {
1453 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1454 }
1455
1456 async fn validate_webhook_url_with_dns_default(url: &str) -> A2aResult<Option<SocketAddr>> {
1459 validate_webhook_url_with_dns(url, DEFAULT_DNS_LOOKUP_TIMEOUT).await
1460 }
1461
1462 #[tokio::test(start_paused = true)]
1467 async fn a_hung_resolver_is_bounded_by_the_dns_timeout() {
1468 let started = tokio::time::Instant::now();
1469 let err = validate_webhook_url_with_resolver(
1470 "https://webhook.example/hook",
1471 std::time::Duration::from_secs(3),
1472 |_addr| std::future::pending::<std::io::Result<Vec<SocketAddr>>>(),
1473 )
1474 .await
1475 .expect_err("a lookup that never returns must not hold the delivery");
1476 assert_eq!(started.elapsed(), std::time::Duration::from_secs(3));
1477 assert!(
1478 err.message.contains("resolution timed out after 3s")
1479 && err.message.contains("webhook.example"),
1480 "got: {}",
1481 err.message
1482 );
1483 }
1484
1485 #[tokio::test]
1489 async fn the_resolver_seam_checks_and_pins_what_it_is_given() {
1490 let private = validate_webhook_url_with_resolver(
1491 "http://webhook.example/hook",
1492 std::time::Duration::from_secs(1),
1493 |_addr| async { Ok(vec!["10.0.0.7:80".parse().unwrap()]) },
1494 )
1495 .await
1496 .expect_err("a private answer is refused");
1497 assert!(
1498 private.message.contains("private/loopback"),
1499 "got: {}",
1500 private.message
1501 );
1502
1503 let pinned = validate_webhook_url_with_resolver(
1504 "http://webhook.example/hook",
1505 std::time::Duration::from_secs(1),
1506 |addr| async move {
1507 assert_eq!(
1508 addr, "webhook.example:80",
1509 "the lookup carries the scheme's port"
1510 );
1511 Ok(vec!["203.0.113.9:80".parse().unwrap()])
1512 },
1513 )
1514 .await
1515 .expect("a public answer is accepted");
1516 assert_eq!(pinned, Some("203.0.113.9:80".parse().unwrap()));
1517 }
1518
1519 #[tokio::test]
1522 async fn dns_rejects_loopback_ip_literal() {
1523 let result = validate_webhook_url_with_dns_default("http://127.0.0.1:8080/webhook").await;
1525 assert!(result.is_err(), "loopback IP should be rejected");
1526 }
1527
1528 #[tokio::test]
1529 async fn dns_rejects_private_ip_literal() {
1530 let result = validate_webhook_url_with_dns_default("http://10.0.0.1/webhook").await;
1531 assert!(result.is_err(), "private IP should be rejected");
1532 }
1533
1534 #[tokio::test]
1535 async fn dns_rejects_localhost_hostname() {
1536 let result = validate_webhook_url_with_dns_default("http://localhost:8080/webhook").await;
1538 assert!(result.is_err(), "localhost should be rejected");
1539 }
1540
1541 #[tokio::test]
1542 async fn dns_rejects_invalid_scheme() {
1543 let result = validate_webhook_url_with_dns_default("ftp://example.com/webhook").await;
1544 assert!(result.is_err(), "ftp scheme should be rejected");
1545 }
1546
1547 #[tokio::test]
1548 async fn dns_rejects_missing_host() {
1549 let result = validate_webhook_url_with_dns_default("http:///path").await;
1550 assert!(result.is_err(), "missing host should be rejected");
1551 }
1552
1553 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1554 async fn dns_rejects_unresolvable_hostname() {
1555 let (tx, rx) = tokio::sync::oneshot::channel();
1558 std::thread::spawn(move || {
1559 let rt = tokio::runtime::Builder::new_current_thread()
1560 .enable_all()
1561 .build()
1562 .unwrap();
1563 let result = rt.block_on(validate_webhook_url_with_dns_default(
1564 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1565 ));
1566 let _ = tx.send(result);
1567 });
1568 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1569 Ok(Ok(result)) => {
1570 assert!(result.is_err(), "unresolvable hostname should be rejected");
1571 }
1572 Ok(Err(_)) => panic!("sender dropped without sending"),
1573 Err(_elapsed) => {
1574 }
1576 }
1577 }
1578
1579 #[tokio::test]
1580 async fn dns_accepts_ip_literal_public() {
1581 let result = validate_webhook_url_with_dns_default("https://203.0.113.1/webhook").await;
1584 assert!(
1585 matches!(result, Ok(None)),
1586 "public IP literal should be accepted with no pinning (got {result:?})",
1587 );
1588 }
1589
1590 #[test]
1593 fn rewrite_uri_preserves_scheme_path_and_query() {
1594 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1595 let rewritten =
1596 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1597 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1598 }
1599
1600 #[test]
1601 fn rewrite_uri_uses_ipv6_brackets() {
1602 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1603 let rewritten =
1604 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1605 assert!(
1607 rewritten.to_string().contains("[2001:db8::1]:443"),
1608 "IPv6 literal should be bracketed: {rewritten}",
1609 );
1610 }
1611
1612 #[test]
1613 fn rewrite_uri_default_path_when_missing() {
1614 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1615 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1616 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1617 }
1618
1619 #[test]
1620 fn host_header_includes_port_when_present() {
1621 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1622 assert_eq!(host, "example.com:8080");
1623 }
1624
1625 #[test]
1626 fn host_header_omits_default_port() {
1627 let host = host_header_from_url("https://example.com/webhook").unwrap();
1628 assert_eq!(host, "example.com");
1629 }
1630
1631 #[test]
1632 fn host_header_from_url_rejects_missing_host() {
1633 let result = host_header_from_url("http:///path");
1634 assert!(result.is_err());
1635 }
1636
1637 #[test]
1638 fn pin_target_pins_http_but_not_https() {
1639 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1640 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1642 assert_eq!(pin_target(true, Some(addr)), None);
1645 assert_eq!(pin_target(false, None), None);
1647 assert_eq!(pin_target(true, None), None);
1648 }
1649
1650 fn dummy_event() -> StreamResponse {
1653 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1654 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1655 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1656 task_id: TaskId::new("t1"),
1657 context_id: ContextId::new("c1"),
1658 status: TaskStatus::with_timestamp(TaskState::Working),
1659 metadata: None,
1660 })
1661 }
1662
1663 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1664 TaskPushNotificationConfig {
1665 tenant: None,
1666 id: Some("cfg".to_owned()),
1667 task_id: Some("t1".to_owned()),
1668 url: url.to_owned(),
1669 token: None,
1670 authentication: None,
1671 }
1672 }
1673
1674 #[cfg(not(feature = "tls-rustls"))]
1677 #[tokio::test]
1678 async fn https_without_tls_feature_fails_fast() {
1679 let sender = HttpPushSender::new();
1680 let event = dummy_event();
1681 let config = dummy_config("https://example.com/webhook");
1682 let err = sender
1683 .send(&config.url, &event, &config)
1684 .await
1685 .expect_err("https must fail fast without the tls-rustls feature");
1686 assert!(
1687 err.to_string().contains("HTTP only"),
1688 "expected the HTTP-only error, got: {err}"
1689 );
1690 }
1691
1692 #[cfg(feature = "tls-rustls")]
1696 #[tokio::test]
1697 async fn https_with_tls_feature_still_enforces_ssrf() {
1698 let sender = HttpPushSender::new();
1699 let event = dummy_event();
1700 let config = dummy_config("https://127.0.0.1:8443/webhook");
1701 let err = sender
1702 .send(&config.url, &event, &config)
1703 .await
1704 .expect_err("https to a loopback address must be rejected by SSRF");
1705 let msg = err.to_string();
1706 assert!(
1707 msg.contains("private/loopback") || msg.contains("loopback"),
1708 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1709 );
1710 }
1711}
1712
1713#[cfg(test)]
1714mod port_tests {
1715 use super::webhook_port;
1716
1717 #[test]
1721 fn scheme_defaults_are_not_swapped() {
1722 let port = |u: &str| webhook_port(&u.parse::<hyper::Uri>().expect("uri"));
1723
1724 assert_eq!(
1725 port("https://example.com/hook"),
1726 443,
1727 "https defaults to 443"
1728 );
1729 assert_eq!(port("http://example.com/hook"), 80, "http defaults to 80");
1730 assert_eq!(port("https://example.com:8443/hook"), 8443);
1733 assert_eq!(port("http://example.com:8080/hook"), 8080);
1734 }
1735}