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 let target = super::webhook::origin_for_log(url);
779 let _ = ⌖
780 trace_info!(target = %target, "delivering push notification");
781
782 let is_https = url
783 .split_once("://")
784 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
785
786 #[cfg(not(feature = "tls-rustls"))]
792 if is_https {
793 return Err(A2aError::internal(
794 "this build of HttpPushSender delivers over HTTP only and cannot reach an \
795 https:// webhook; enable the `tls-rustls` feature (on by default in \
796 a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
797 ));
798 }
799
800 let pinned_addr = if self.allow_private_urls {
804 None
805 } else {
806 validate_webhook_url_with_dns(url, self.dns_timeout).await?
807 };
808
809 let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
817 Some(addr) => (
818 Some(rewrite_uri_with_pinned_addr(url, addr)?),
819 Some(host_header_from_url(url)?),
820 ),
821 None => (None, None),
822 };
823
824 if let Some(ref auth) = config.authentication {
826 if let Some(ref credentials) = auth.credentials {
827 validate_header_value(credentials, "authentication credentials")?;
828 }
829 validate_header_value(&auth.scheme, "authentication scheme")?;
830 }
831 if let Some(ref token) = config.token {
832 validate_header_value(token, "notification token")?;
833 }
834
835 let body_bytes: Bytes = serde_json::to_vec(event)
836 .map(Bytes::from)
837 .map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
838
839 let mut last_err = String::new();
840
841 for attempt in 0..self.retry_policy.max_attempts {
842 let mut builder = hyper::Request::builder()
854 .method(hyper::Method::POST)
855 .header("content-type", a2a_protocol_types::A2A_CONTENT_TYPE);
856
857 if let Some(uri) = pinned_uri.as_ref() {
858 builder = builder.uri(uri.clone());
859 if let Some(host) = pinned_host_header.as_deref() {
860 builder = builder.header("host", host);
861 }
862 } else {
863 builder = builder.uri(url);
864 }
865
866 if let Some(ref auth) = config.authentication {
873 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
874 Some("Bearer")
875 } else if auth.scheme.eq_ignore_ascii_case("basic") {
876 Some("Basic")
877 } else {
878 None
879 };
880 match (canonical_scheme, auth.credentials.as_deref()) {
881 (Some(prefix), Some(credentials)) => {
882 builder =
883 builder.header("authorization", format!("{prefix} {credentials}"));
884 }
885 (Some(_), None) => {
886 trace_warn!(
887 scheme = auth.scheme.as_str(),
888 "authentication scheme has no credentials; no auth header set"
889 );
890 }
891 (None, _) => {
892 trace_warn!(
893 scheme = auth.scheme.as_str(),
894 "unknown authentication scheme; no auth header set"
895 );
896 }
897 }
898 }
899
900 if let Some(ref token) = config.token {
918 builder = builder
919 .header(super::webhook::NOTIFICATION_TOKEN_HEADER, token.as_str())
920 .header(
921 super::webhook::NOTIFICATION_TOKEN_HEADER_UNPREFIXED,
922 token.as_str(),
923 );
924 }
925
926 let req = builder
927 .body(Full::new(body_bytes.clone()))
928 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
929
930 let request_result =
931 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
932
933 match request_result {
934 Ok(Ok(resp)) if resp.status().is_success() => {
935 trace_debug!(target = %target, "push notification delivered");
936 return Ok(());
937 }
938 Ok(Ok(resp)) => {
939 let status = resp.status();
940 let retryable = status.is_server_error()
946 || status == hyper::StatusCode::REQUEST_TIMEOUT
947 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
948 if !retryable {
949 trace_warn!(target = %target, attempt, status = %status, "push delivery rejected; not retrying");
950 return Err(A2aError::internal(format!(
951 "push notification got non-retryable HTTP {status}"
952 )));
953 }
954 last_err = format!("push notification got HTTP {status}");
955 trace_warn!(target = %target, attempt, status = %status, "push delivery failed");
956 }
957 Ok(Err(e)) => {
958 last_err = format!("push notification failed: {e}");
959 trace_warn!(target = %target, attempt, error = %e, "push delivery error");
960 }
961 Err(_) => {
962 last_err = format!(
963 "push notification timed out after {}s",
964 self.request_timeout.as_secs()
965 );
966 trace_warn!(target = %target, attempt, "push delivery timed out");
967 }
968 }
969
970 if attempt < self.retry_policy.max_attempts - 1 {
972 let delay = self
973 .retry_policy
974 .backoff
975 .get(attempt)
976 .or_else(|| self.retry_policy.backoff.last());
977 if let Some(delay) = delay {
978 tokio::time::sleep(*delay).await;
979 }
980 }
981 }
982
983 Err(A2aError::internal(last_err))
984 })
985 }
986}
987
988#[cfg(test)]
991mod tests {
992 use super::*;
993
994 #[test]
998 fn a_sender_that_does_not_report_a_schedule_reports_none() {
999 struct SaysNothing;
1000 impl PushSender for SaysNothing {
1001 fn send<'a>(
1002 &'a self,
1003 _url: &'a str,
1004 _event: &'a StreamResponse,
1005 _config: &'a TaskPushNotificationConfig,
1006 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1007 Box::pin(async { Ok(()) })
1008 }
1009 }
1010 assert_eq!(SaysNothing.max_delivery_duration(), None);
1011 }
1012
1013 #[test]
1015 fn push_retry_policy_with_max_attempts() {
1016 let policy = PushRetryPolicy::default().with_max_attempts(5);
1017 assert_eq!(policy.max_attempts, 5);
1018 assert_eq!(policy.backoff.len(), 2);
1020 }
1021
1022 #[test]
1024 fn push_retry_policy_with_backoff() {
1025 let backoff = vec![
1026 std::time::Duration::from_millis(100),
1027 std::time::Duration::from_millis(500),
1028 std::time::Duration::from_secs(1),
1029 ];
1030 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
1031 assert_eq!(policy.backoff, backoff);
1032 assert_eq!(policy.max_attempts, 3);
1034 }
1035
1036 #[test]
1038 fn http_push_sender_with_retry_policy() {
1039 let policy = PushRetryPolicy::default().with_max_attempts(10);
1040 let sender = HttpPushSender::new().with_retry_policy(policy);
1041 assert_eq!(sender.retry_policy.max_attempts, 10);
1042 }
1043
1044 #[test]
1046 fn rejects_url_without_host() {
1047 assert!(validate_webhook_url("http:///path").is_err());
1048 }
1049
1050 #[test]
1052 fn http_push_sender_allow_private_urls() {
1053 let sender = HttpPushSender::new().allow_private_urls();
1054 assert!(sender.allow_private_urls);
1055 }
1056
1057 #[test]
1059 fn http_push_sender_default() {
1060 let sender = HttpPushSender::default();
1061 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
1062 assert!(!sender.allow_private_urls);
1063 }
1064
1065 #[test]
1067 fn push_retry_policy_default() {
1068 let policy = PushRetryPolicy::default();
1069 assert_eq!(policy.max_attempts, 3);
1070 assert_eq!(policy.backoff.len(), 2);
1071 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
1072 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
1073 }
1074
1075 #[test]
1076 fn rejects_loopback_ipv4() {
1077 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
1078 }
1079
1080 #[test]
1081 fn rejects_private_10_range() {
1082 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
1083 }
1084
1085 #[test]
1086 fn rejects_private_172_range() {
1087 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
1088 }
1089
1090 #[test]
1091 fn rejects_private_192_168_range() {
1092 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
1093 }
1094
1095 #[test]
1096 fn rejects_link_local() {
1097 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
1098 }
1099
1100 #[test]
1101 fn parse_numeric_ipv4_matches_the_resolver() {
1102 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
1103 assert_eq!(
1105 parse_numeric_ipv4("2852039166"),
1106 Some(v4("169.254.169.254"))
1107 ); assert_eq!(
1109 parse_numeric_ipv4("0xA9FEA9FE"),
1110 Some(v4("169.254.169.254"))
1111 ); assert_eq!(
1113 parse_numeric_ipv4("0xa9fea9fe"),
1114 Some(v4("169.254.169.254"))
1115 ); assert_eq!(
1117 parse_numeric_ipv4("0251.0376.0251.0376"),
1118 Some(v4("169.254.169.254")) );
1120 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")));
1127 assert_eq!(parse_numeric_ipv4("example.com"), None);
1129 assert_eq!(parse_numeric_ipv4("printer.local"), None);
1130 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); }
1136
1137 #[test]
1146 fn parse_numeric_ipv4_packs_every_part_count_exactly() {
1147 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
1148
1149 assert_eq!(parse_numeric_ipv4("1"), Some(v4("0.0.0.1")));
1151 assert_eq!(parse_numeric_ipv4("1.2"), Some(v4("1.0.0.2")));
1152 assert_eq!(parse_numeric_ipv4("1.2.3"), Some(v4("1.2.0.3")));
1153 assert_eq!(parse_numeric_ipv4("1.2.3.4"), Some(v4("1.2.3.4")));
1154 assert_eq!(parse_numeric_ipv4("0177.0.0.01"), Some(v4("127.0.0.1")));
1155
1156 assert_eq!(
1159 parse_numeric_ipv4("4294967295"),
1160 Some(v4("255.255.255.255"))
1161 );
1162 assert_eq!(parse_numeric_ipv4("4294967296"), None);
1163 assert_eq!(parse_numeric_ipv4("1.16777215"), Some(v4("1.255.255.255")));
1164 assert_eq!(parse_numeric_ipv4("1.16777216"), None);
1165 assert_eq!(parse_numeric_ipv4("1.2.65535"), Some(v4("1.2.255.255")));
1166 assert_eq!(parse_numeric_ipv4("1.2.65536"), None);
1167 assert_eq!(parse_numeric_ipv4("1.2.3.255"), Some(v4("1.2.3.255")));
1168 assert_eq!(parse_numeric_ipv4("1.2.3.256"), None);
1169
1170 assert_eq!(parse_numeric_ipv4("255.1"), Some(v4("255.0.0.1")));
1172 assert_eq!(parse_numeric_ipv4("256.1"), None);
1173 assert_eq!(parse_numeric_ipv4("1.255.1"), Some(v4("1.255.0.1")));
1174 assert_eq!(parse_numeric_ipv4("1.256.1"), None);
1175 assert_eq!(parse_numeric_ipv4("1.2.255.1"), Some(v4("1.2.255.1")));
1176 assert_eq!(parse_numeric_ipv4("1.2.256.1"), None);
1177
1178 assert_eq!(parse_numeric_ipv4("1.2.3.4.5"), None);
1181 assert_eq!(parse_numeric_ipv4("1.2.3.4.5.6"), None);
1182
1183 assert_eq!(parse_numeric_ipv4(""), None);
1185 assert_eq!(parse_numeric_ipv4("."), None);
1186 assert_eq!(parse_numeric_ipv4("1..2"), None);
1187 assert_eq!(parse_numeric_ipv4("1.2."), None);
1188 assert_eq!(parse_numeric_ipv4("-1"), None);
1189 assert_eq!(parse_numeric_ipv4("1.-1"), None);
1190 }
1191
1192 #[test]
1193 fn rejects_decimal_integer_metadata() {
1194 assert!(validate_webhook_url("http://2852039166/latest/meta-data").is_err());
1197 }
1198
1199 #[test]
1200 fn rejects_hex_integer_metadata() {
1201 assert!(validate_webhook_url("http://0xA9FEA9FE/latest").is_err());
1202 }
1203
1204 #[test]
1205 fn rejects_octal_dotted_metadata() {
1206 assert!(validate_webhook_url("http://0251.0376.0251.0376/latest").is_err());
1207 }
1208
1209 #[test]
1210 fn rejects_packed_short_form_loopback() {
1211 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());
1214 }
1215
1216 #[test]
1217 fn accepts_public_numeric_ip() {
1218 assert!(validate_webhook_url("http://134744072/webhook").is_ok());
1221 }
1222
1223 #[test]
1224 fn max_delivery_duration_matches_the_retry_loop_it_describes() {
1225 use std::time::Duration;
1226
1227 let sender = HttpPushSender::new();
1229 assert_eq!(
1230 sender.max_delivery_duration(),
1231 Some(Duration::from_secs(5 + 30 + 1 + 30 + 2 + 30)),
1232 "the default schedule is 98 seconds: 5 of DNS, then 93 of requests and backoff"
1233 );
1234
1235 let one = HttpPushSender::with_timeout(Duration::from_secs(7))
1237 .with_retry_policy(PushRetryPolicy::default().with_max_attempts(1))
1238 .with_dns_timeout(Duration::from_secs(1));
1239 assert_eq!(
1240 one.max_delivery_duration(),
1241 Some(Duration::from_secs(1 + 7))
1242 );
1243
1244 let many = HttpPushSender::with_timeout(Duration::from_secs(1))
1247 .with_retry_policy(
1248 PushRetryPolicy::default().with_max_attempts(5), )
1250 .with_dns_timeout(Duration::ZERO);
1251 assert_eq!(
1252 many.max_delivery_duration(),
1253 Some(Duration::from_secs(5 + 1 + 2 + 2 + 2)),
1254 "backoff falls back to its last entry, as `send` does"
1255 );
1256 }
1257
1258 #[test]
1265 fn the_default_schedule_does_not_fit_the_default_handler_bound() {
1266 use crate::handler::HandlerLimits;
1267
1268 let wanted = HttpPushSender::new()
1269 .max_delivery_duration()
1270 .expect("HttpPushSender reports its schedule");
1271 let allowed = HandlerLimits::default().push_delivery_timeout;
1272
1273 assert!(
1274 wanted > allowed,
1275 "if these no longer contradict, the fix landed — update this test \
1276 and the arithmetic in HandlerLimits::push_delivery_timeout's docs. \
1277 sender wants {wanted:?}, handler allows {allowed:?}"
1278 );
1279
1280 let policy = PushRetryPolicy::default();
1284 let request = std::time::Duration::from_secs(30);
1285 let mut spent = std::time::Duration::ZERO;
1286 let mut attempts = 0_usize;
1287 for i in 0..policy.max_attempts {
1288 if spent >= allowed {
1289 break;
1290 }
1291 attempts += 1;
1292 spent += request;
1293 if let Some(d) = policy.backoff.get(i).or_else(|| policy.backoff.last()) {
1294 spent += *d;
1295 }
1296 }
1297 assert_eq!(
1298 attempts, 1,
1299 "at the shipped defaults one attempt of {} runs",
1300 policy.max_attempts
1301 );
1302 }
1303
1304 #[test]
1305 fn rejects_localhost() {
1306 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
1307 }
1308
1309 #[test]
1310 fn rejects_dot_local() {
1311 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
1312 }
1313
1314 #[test]
1315 fn rejects_dot_internal() {
1316 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
1317 }
1318
1319 #[test]
1320 fn rejects_ipv6_loopback() {
1321 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
1322 }
1323
1324 #[test]
1331 fn rejects_ipv4_mapped_loopback() {
1332 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
1333 }
1334
1335 #[test]
1336 fn rejects_ipv4_mapped_metadata() {
1337 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
1338 }
1339
1340 #[test]
1341 fn rejects_ipv4_mapped_private() {
1342 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
1343 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
1344 }
1345
1346 #[test]
1347 fn rejects_ipv4_compatible_loopback() {
1348 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
1350 }
1351
1352 #[test]
1353 fn rejects_nat64_wellknown_prefix_to_private() {
1354 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
1356 }
1357
1358 #[test]
1359 fn accepts_ipv4_mapped_public() {
1360 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
1362 }
1363
1364 #[test]
1372 fn is_private_v4_cgnat_boundary() {
1373 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
1375 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()));
1381 }
1382
1383 #[test]
1384 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
1385 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
1386 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
1387
1388 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
1390 assert_eq!(
1391 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
1392 v4("169.254.169.254")
1393 );
1394 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
1397 assert_eq!(embedded_ipv4(v6("::")), None);
1398 assert_eq!(embedded_ipv4(v6("::1")), None);
1399 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
1401 }
1402
1403 #[test]
1404 fn accepts_public_url() {
1405 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
1406 }
1407
1408 #[test]
1409 fn accepts_public_ip() {
1410 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
1411 }
1412
1413 #[test]
1414 fn rejects_header_with_crlf() {
1415 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
1416 }
1417
1418 #[test]
1419 fn rejects_header_with_cr() {
1420 assert!(validate_header_value("token\rvalue", "test").is_err());
1421 }
1422
1423 #[test]
1424 fn rejects_header_with_lf() {
1425 assert!(validate_header_value("token\nvalue", "test").is_err());
1426 }
1427
1428 #[test]
1429 fn accepts_clean_header_value() {
1430 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
1431 }
1432
1433 #[test]
1434 fn rejects_url_without_scheme() {
1435 assert!(validate_webhook_url("example.com/webhook").is_err());
1436 }
1437
1438 #[test]
1439 fn rejects_ftp_scheme() {
1440 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
1441 }
1442
1443 #[test]
1444 fn rejects_file_scheme() {
1445 assert!(validate_webhook_url("file:///etc/passwd").is_err());
1446 }
1447
1448 #[test]
1449 fn accepts_http_scheme() {
1450 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
1451 }
1452
1453 #[test]
1454 fn rejects_cgnat_range() {
1455 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
1456 }
1457
1458 #[test]
1459 fn rejects_unspecified_ipv4() {
1460 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
1461 }
1462
1463 #[test]
1464 fn rejects_ipv6_unique_local() {
1465 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1466 }
1467
1468 #[test]
1469 fn rejects_ipv6_link_local() {
1470 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1471 }
1472
1473 async fn validate_webhook_url_with_dns_default(url: &str) -> A2aResult<Option<SocketAddr>> {
1476 validate_webhook_url_with_dns(url, DEFAULT_DNS_LOOKUP_TIMEOUT).await
1477 }
1478
1479 #[tokio::test(start_paused = true)]
1484 async fn a_hung_resolver_is_bounded_by_the_dns_timeout() {
1485 let started = tokio::time::Instant::now();
1486 let err = validate_webhook_url_with_resolver(
1487 "https://webhook.example/hook",
1488 std::time::Duration::from_secs(3),
1489 |_addr| std::future::pending::<std::io::Result<Vec<SocketAddr>>>(),
1490 )
1491 .await
1492 .expect_err("a lookup that never returns must not hold the delivery");
1493 assert_eq!(started.elapsed(), std::time::Duration::from_secs(3));
1494 assert!(
1495 err.message.contains("resolution timed out after 3s")
1496 && err.message.contains("webhook.example"),
1497 "got: {}",
1498 err.message
1499 );
1500 }
1501
1502 #[tokio::test]
1506 async fn the_resolver_seam_checks_and_pins_what_it_is_given() {
1507 let private = validate_webhook_url_with_resolver(
1508 "http://webhook.example/hook",
1509 std::time::Duration::from_secs(1),
1510 |_addr| async { Ok(vec!["10.0.0.7:80".parse().unwrap()]) },
1511 )
1512 .await
1513 .expect_err("a private answer is refused");
1514 assert!(
1515 private.message.contains("private/loopback"),
1516 "got: {}",
1517 private.message
1518 );
1519
1520 let pinned = validate_webhook_url_with_resolver(
1521 "http://webhook.example/hook",
1522 std::time::Duration::from_secs(1),
1523 |addr| async move {
1524 assert_eq!(
1525 addr, "webhook.example:80",
1526 "the lookup carries the scheme's port"
1527 );
1528 Ok(vec!["203.0.113.9:80".parse().unwrap()])
1529 },
1530 )
1531 .await
1532 .expect("a public answer is accepted");
1533 assert_eq!(pinned, Some("203.0.113.9:80".parse().unwrap()));
1534 }
1535
1536 #[tokio::test]
1539 async fn dns_rejects_loopback_ip_literal() {
1540 let result = validate_webhook_url_with_dns_default("http://127.0.0.1:8080/webhook").await;
1542 assert!(result.is_err(), "loopback IP should be rejected");
1543 }
1544
1545 #[tokio::test]
1546 async fn dns_rejects_private_ip_literal() {
1547 let result = validate_webhook_url_with_dns_default("http://10.0.0.1/webhook").await;
1548 assert!(result.is_err(), "private IP should be rejected");
1549 }
1550
1551 #[tokio::test]
1552 async fn dns_rejects_localhost_hostname() {
1553 let result = validate_webhook_url_with_dns_default("http://localhost:8080/webhook").await;
1555 assert!(result.is_err(), "localhost should be rejected");
1556 }
1557
1558 #[tokio::test]
1559 async fn dns_rejects_invalid_scheme() {
1560 let result = validate_webhook_url_with_dns_default("ftp://example.com/webhook").await;
1561 assert!(result.is_err(), "ftp scheme should be rejected");
1562 }
1563
1564 #[tokio::test]
1565 async fn dns_rejects_missing_host() {
1566 let result = validate_webhook_url_with_dns_default("http:///path").await;
1567 assert!(result.is_err(), "missing host should be rejected");
1568 }
1569
1570 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1571 async fn dns_rejects_unresolvable_hostname() {
1572 let (tx, rx) = tokio::sync::oneshot::channel();
1575 std::thread::spawn(move || {
1576 let rt = tokio::runtime::Builder::new_current_thread()
1577 .enable_all()
1578 .build()
1579 .unwrap();
1580 let result = rt.block_on(validate_webhook_url_with_dns_default(
1581 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1582 ));
1583 let _ = tx.send(result);
1584 });
1585 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1586 Ok(Ok(result)) => {
1587 assert!(result.is_err(), "unresolvable hostname should be rejected");
1588 }
1589 Ok(Err(_)) => panic!("sender dropped without sending"),
1590 Err(_elapsed) => {
1591 }
1593 }
1594 }
1595
1596 #[tokio::test]
1597 async fn dns_accepts_ip_literal_public() {
1598 let result = validate_webhook_url_with_dns_default("https://203.0.113.1/webhook").await;
1601 assert!(
1602 matches!(result, Ok(None)),
1603 "public IP literal should be accepted with no pinning (got {result:?})",
1604 );
1605 }
1606
1607 #[test]
1610 fn rewrite_uri_preserves_scheme_path_and_query() {
1611 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1612 let rewritten =
1613 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1614 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1615 }
1616
1617 #[test]
1618 fn rewrite_uri_uses_ipv6_brackets() {
1619 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1620 let rewritten =
1621 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1622 assert!(
1624 rewritten.to_string().contains("[2001:db8::1]:443"),
1625 "IPv6 literal should be bracketed: {rewritten}",
1626 );
1627 }
1628
1629 #[test]
1630 fn rewrite_uri_default_path_when_missing() {
1631 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1632 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1633 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1634 }
1635
1636 #[test]
1637 fn host_header_includes_port_when_present() {
1638 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1639 assert_eq!(host, "example.com:8080");
1640 }
1641
1642 #[test]
1643 fn host_header_omits_default_port() {
1644 let host = host_header_from_url("https://example.com/webhook").unwrap();
1645 assert_eq!(host, "example.com");
1646 }
1647
1648 #[test]
1649 fn host_header_from_url_rejects_missing_host() {
1650 let result = host_header_from_url("http:///path");
1651 assert!(result.is_err());
1652 }
1653
1654 #[test]
1655 fn pin_target_pins_http_but_not_https() {
1656 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1657 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1659 assert_eq!(pin_target(true, Some(addr)), None);
1662 assert_eq!(pin_target(false, None), None);
1664 assert_eq!(pin_target(true, None), None);
1665 }
1666
1667 fn dummy_event() -> StreamResponse {
1670 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1671 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1672 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1673 task_id: TaskId::new("t1"),
1674 context_id: ContextId::new("c1"),
1675 status: TaskStatus::with_timestamp(TaskState::Working),
1676 metadata: None,
1677 })
1678 }
1679
1680 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1681 TaskPushNotificationConfig {
1682 tenant: None,
1683 id: Some("cfg".to_owned()),
1684 task_id: Some("t1".to_owned()),
1685 url: url.to_owned(),
1686 token: None,
1687 authentication: None,
1688 }
1689 }
1690
1691 #[cfg(not(feature = "tls-rustls"))]
1694 #[tokio::test]
1695 async fn https_without_tls_feature_fails_fast() {
1696 let sender = HttpPushSender::new();
1697 let event = dummy_event();
1698 let config = dummy_config("https://example.com/webhook");
1699 let err = sender
1700 .send(&config.url, &event, &config)
1701 .await
1702 .expect_err("https must fail fast without the tls-rustls feature");
1703 assert!(
1704 err.to_string().contains("HTTP only"),
1705 "expected the HTTP-only error, got: {err}"
1706 );
1707 }
1708
1709 #[cfg(feature = "tls-rustls")]
1713 #[tokio::test]
1714 async fn https_with_tls_feature_still_enforces_ssrf() {
1715 let sender = HttpPushSender::new();
1716 let event = dummy_event();
1717 let config = dummy_config("https://127.0.0.1:8443/webhook");
1718 let err = sender
1719 .send(&config.url, &event, &config)
1720 .await
1721 .expect_err("https to a loopback address must be rejected by SSRF");
1722 let msg = err.to_string();
1723 assert!(
1724 msg.contains("private/loopback") || msg.contains("loopback"),
1725 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1726 );
1727 }
1728}
1729
1730#[cfg(test)]
1731mod port_tests {
1732 use super::webhook_port;
1733
1734 #[test]
1738 fn scheme_defaults_are_not_swapped() {
1739 let port = |u: &str| webhook_port(&u.parse::<hyper::Uri>().expect("uri"));
1740
1741 assert_eq!(
1742 port("https://example.com/hook"),
1743 443,
1744 "https defaults to 443"
1745 );
1746 assert_eq!(port("http://example.com/hook"), 80, "http defaults to 80");
1747 assert_eq!(port("https://example.com:8443/hook"), 8443);
1750 assert_eq!(port("http://example.com:8080/hook"), 8080);
1751 }
1752}