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 fn max_delivery_duration(&self) -> Option<std::time::Duration> {
133 None
134 }
135}
136
137const DEFAULT_PUSH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
139
140#[derive(Debug, Clone)]
157pub struct PushRetryPolicy {
158 pub max_attempts: usize,
160 pub backoff: Vec<std::time::Duration>,
165}
166
167impl Default for PushRetryPolicy {
168 fn default() -> Self {
169 Self {
170 max_attempts: 3,
171 backoff: vec![
172 std::time::Duration::from_secs(1),
173 std::time::Duration::from_secs(2),
174 ],
175 }
176 }
177}
178
179impl PushRetryPolicy {
180 #[must_use]
182 pub const fn with_max_attempts(mut self, max: usize) -> Self {
183 self.max_attempts = max;
184 self
185 }
186
187 #[must_use]
189 pub fn with_backoff(mut self, backoff: Vec<std::time::Duration>) -> Self {
190 self.backoff = backoff;
191 self
192 }
193}
194
195#[derive(Debug)]
232pub struct HttpPushSender {
233 client: PushHttpClient,
234 request_timeout: std::time::Duration,
235 retry_policy: PushRetryPolicy,
236 allow_private_urls: bool,
238}
239
240impl Default for HttpPushSender {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246impl HttpPushSender {
247 #[must_use]
250 pub fn new() -> Self {
251 Self::with_timeout(DEFAULT_PUSH_REQUEST_TIMEOUT)
252 }
253
254 #[must_use]
256 pub fn with_timeout(request_timeout: std::time::Duration) -> Self {
257 let client = build_push_http_client();
258 Self {
259 client,
260 request_timeout,
261 retry_policy: PushRetryPolicy::default(),
262 allow_private_urls: false,
263 }
264 }
265
266 #[cfg(feature = "tls-rustls")]
277 #[must_use]
278 pub fn with_tls_config(tls_config: rustls::ClientConfig) -> Self {
279 Self {
280 client: build_push_https_client(tls_config),
281 request_timeout: DEFAULT_PUSH_REQUEST_TIMEOUT,
282 retry_policy: PushRetryPolicy::default(),
283 allow_private_urls: false,
284 }
285 }
286
287 #[must_use]
289 pub fn with_retry_policy(mut self, policy: PushRetryPolicy) -> Self {
290 self.retry_policy = policy;
291 self
292 }
293
294 #[must_use]
299 pub const fn allow_private_urls(mut self) -> Self {
300 self.allow_private_urls = true;
301 self
302 }
303}
304
305#[allow(clippy::missing_const_for_fn)] fn is_private_v4(v4: Ipv4Addr) -> bool {
309 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) }
315
316fn parse_numeric_ipv4(host: &str) -> Option<Ipv4Addr> {
330 let parts: Vec<&str> = host.split('.').collect();
332 if parts.is_empty() || parts.len() > 4 {
333 return None;
334 }
335 let mut vals: Vec<u64> = Vec::with_capacity(parts.len());
336 for p in &parts {
337 vals.push(parse_c_integer(p)?);
338 }
339 let addr: u64 = match vals.as_slice() {
342 [a] => *a,
343 [a, b] if *a <= 0xff && *b <= 0x00ff_ffff => (a << 24) | b,
344 [a, b, c] if *a <= 0xff && *b <= 0xff && *c <= 0xffff => (a << 24) | (b << 16) | c,
345 [a, b, c, d] if *a <= 0xff && *b <= 0xff && *c <= 0xff && *d <= 0xff => {
346 (a << 24) | (b << 16) | (c << 8) | d
347 }
348 _ => return None,
349 };
350 if addr > u64::from(u32::MAX) {
351 return None;
352 }
353 #[allow(clippy::cast_possible_truncation)] Some(Ipv4Addr::from((addr as u32).to_be_bytes()))
355}
356
357fn parse_c_integer(s: &str) -> Option<u64> {
361 if s.is_empty() {
362 return None;
363 }
364 if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
365 if hex.is_empty() {
366 return None;
367 }
368 u64::from_str_radix(hex, 16).ok()
369 } else if s.len() > 1 && s.starts_with('0') {
370 u64::from_str_radix(&s[1..], 8).ok()
371 } else {
372 s.parse::<u64>().ok()
373 }
374}
375
376fn embedded_ipv4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
385 if let Some(v4) = v6.to_ipv4_mapped() {
386 return Some(v4);
387 }
388 let v4_from = |g: u16, h: u16| {
389 let [a, b] = g.to_be_bytes();
390 let [c, d] = h.to_be_bytes();
391 Ipv4Addr::new(a, b, c, d)
392 };
393 match v6.segments() {
394 [0x0064, 0xff9b, 0, 0, 0, 0, g, h] => Some(v4_from(g, h)),
396 [0, 0, 0, 0, 0, 0, g, h] if !(g == 0 && (h == 0 || h == 1)) => Some(v4_from(g, h)),
398 _ => None,
399 }
400}
401
402#[allow(clippy::missing_const_for_fn)] fn is_private_ip(ip: IpAddr) -> bool {
405 match ip {
406 IpAddr::V4(v4) => is_private_v4(v4),
407 IpAddr::V6(v6) => {
408 if let Some(v4) = embedded_ipv4(v6) {
412 return is_private_v4(v4);
413 }
414 v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00
418 || (v6.segments()[0] & 0xffc0) == 0xfe80
420 }
421 }
422}
423
424#[allow(clippy::case_sensitive_file_extension_comparisons)] pub(crate) fn validate_webhook_url(url: &str) -> A2aResult<()> {
430 let uri: hyper::Uri = url
432 .parse()
433 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
434
435 match uri.scheme_str() {
437 Some("http" | "https") => {}
438 Some(other) => {
439 return Err(A2aError::invalid_params(format!(
440 "webhook URL has unsupported scheme: {other} (expected http or https)"
441 )));
442 }
443 None => {
444 return Err(A2aError::invalid_params(
445 "webhook URL missing scheme (expected http:// or https://)",
446 ));
447 }
448 }
449
450 let host = uri
451 .host()
452 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
453
454 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
456
457 if let Ok(ip) = host_bare.parse::<IpAddr>() {
459 if is_private_ip(ip) {
460 return Err(A2aError::invalid_params(format!(
461 "webhook URL targets private/loopback address: {host}"
462 )));
463 }
464 } else if let Some(v4) = parse_numeric_ipv4(host_bare) {
465 if is_private_v4(v4) {
472 return Err(A2aError::invalid_params(format!(
473 "webhook URL targets private/loopback address: {host} ({v4})"
474 )));
475 }
476 }
477
478 let host_lower = host.to_ascii_lowercase();
480 if host_lower == "localhost"
481 || host_lower.ends_with(".local")
482 || host_lower.ends_with(".internal")
483 {
484 return Err(A2aError::invalid_params(format!(
485 "webhook URL targets local/internal hostname: {host}"
486 )));
487 }
488
489 Ok(())
490}
491
492fn webhook_port(uri: &hyper::Uri) -> u16 {
521 if let Some(explicit) = uri.port_u16() {
522 return explicit;
523 }
524 if uri.scheme_str() == Some("https") {
525 443
526 } else {
527 80
528 }
529}
530
531pub(crate) async fn validate_webhook_url_with_dns(url: &str) -> A2aResult<Option<SocketAddr>> {
532 validate_webhook_url(url)?;
534
535 let uri: hyper::Uri = url
537 .parse()
538 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
539
540 let host = uri
541 .host()
542 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
543
544 let host_bare = host.trim_start_matches('[').trim_end_matches(']');
546
547 if host_bare.parse::<IpAddr>().is_ok() {
550 return Ok(None);
551 }
552
553 let port = webhook_port(&uri);
555
556 let addr = format!("{host_bare}:{port}");
557 let resolved = tokio::net::lookup_host(&addr).await.map_err(|e| {
558 A2aError::invalid_params(format!(
559 "webhook URL hostname could not be resolved: {host_bare}: {e}"
560 ))
561 })?;
562
563 let mut pinned: Option<SocketAddr> = None;
564 for socket_addr in resolved {
565 let ip = socket_addr.ip();
566 if is_private_ip(ip) {
567 return Err(A2aError::invalid_params(format!(
568 "webhook URL hostname {host_bare} resolves to private/loopback address: {ip}"
569 )));
570 }
571 if pinned.is_none() {
572 pinned = Some(socket_addr);
573 }
574 }
575
576 pinned
577 .ok_or_else(|| {
578 A2aError::invalid_params(format!(
579 "webhook URL hostname {host_bare} did not resolve to any addresses"
580 ))
581 })
582 .map(Some)
583}
584
585fn rewrite_uri_with_pinned_addr(url: &str, pinned: SocketAddr) -> A2aResult<hyper::Uri> {
595 let uri: hyper::Uri = url
596 .parse()
597 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
598
599 let scheme = uri
600 .scheme_str()
601 .ok_or_else(|| A2aError::invalid_params("webhook URL missing scheme"))?;
602
603 let host_str = match pinned.ip() {
605 IpAddr::V4(v4) => v4.to_string(),
606 IpAddr::V6(v6) => format!("[{v6}]"),
607 };
608
609 let path_and_query = uri
610 .path_and_query()
611 .map_or_else(|| "/".to_string(), std::string::ToString::to_string);
612
613 let rewritten = format!(
614 "{scheme}://{host_str}:{port}{path_and_query}",
615 port = pinned.port()
616 );
617
618 rewritten
619 .parse()
620 .map_err(|e| A2aError::invalid_params(format!("could not rewrite webhook URL: {e}")))
621}
622
623fn host_header_from_url(url: &str) -> A2aResult<String> {
629 let uri: hyper::Uri = url
630 .parse()
631 .map_err(|e| A2aError::invalid_params(format!("invalid webhook URL: {e}")))?;
632 let host = uri
633 .host()
634 .ok_or_else(|| A2aError::invalid_params("webhook URL missing host"))?;
635 Ok(uri
636 .port_u16()
637 .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")))
638}
639
640const fn pin_target(is_https: bool, pinned_addr: Option<SocketAddr>) -> Option<SocketAddr> {
650 match pinned_addr {
651 Some(addr) if !is_https => Some(addr),
652 _ => None,
653 }
654}
655
656fn validate_header_value(value: &str, name: &str) -> A2aResult<()> {
658 if value.contains('\r') || value.contains('\n') {
659 return Err(A2aError::invalid_params(format!(
660 "{name} contains invalid characters (CR/LF)"
661 )));
662 }
663 Ok(())
664}
665
666#[allow(clippy::manual_async_fn, clippy::too_many_lines)]
667impl PushSender for HttpPushSender {
668 fn max_delivery_duration(&self) -> Option<std::time::Duration> {
674 let attempts = self.retry_policy.max_attempts;
675 if attempts == 0 {
676 return Some(std::time::Duration::ZERO);
677 }
678 let mut total = self
679 .request_timeout
680 .saturating_mul(u32::try_from(attempts).unwrap_or(u32::MAX));
681 for attempt in 0..attempts.saturating_sub(1) {
682 if let Some(delay) = self
683 .retry_policy
684 .backoff
685 .get(attempt)
686 .or_else(|| self.retry_policy.backoff.last())
687 {
688 total = total.saturating_add(*delay);
689 }
690 }
691 Some(total)
692 }
693
694 fn allows_private_urls(&self) -> bool {
695 self.allow_private_urls
696 }
697
698 fn send<'a>(
699 &'a self,
700 url: &'a str,
701 event: &'a StreamResponse,
702 config: &'a TaskPushNotificationConfig,
703 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
704 Box::pin(async move {
705 trace_info!(url, "delivering push notification");
706
707 let is_https = url
708 .split_once("://")
709 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
710
711 #[cfg(not(feature = "tls-rustls"))]
717 if is_https {
718 return Err(A2aError::internal(
719 "this build of HttpPushSender delivers over HTTP only and cannot reach an \
720 https:// webhook; enable the `tls-rustls` feature (on by default in \
721 a2a-protocol-sdk) or supply a TLS-capable PushSender implementation",
722 ));
723 }
724
725 let pinned_addr = if self.allow_private_urls {
729 None
730 } else {
731 validate_webhook_url_with_dns(url).await?
732 };
733
734 let (pinned_uri, pinned_host_header) = match pin_target(is_https, pinned_addr) {
742 Some(addr) => (
743 Some(rewrite_uri_with_pinned_addr(url, addr)?),
744 Some(host_header_from_url(url)?),
745 ),
746 None => (None, None),
747 };
748
749 if let Some(ref auth) = config.authentication {
751 if let Some(ref credentials) = auth.credentials {
752 validate_header_value(credentials, "authentication credentials")?;
753 }
754 validate_header_value(&auth.scheme, "authentication scheme")?;
755 }
756 if let Some(ref token) = config.token {
757 validate_header_value(token, "notification token")?;
758 }
759
760 let body_bytes: Bytes = serde_json::to_vec(event)
761 .map(Bytes::from)
762 .map_err(|e| A2aError::internal(format!("push serialization: {e}")))?;
763
764 let mut last_err = String::new();
765
766 for attempt in 0..self.retry_policy.max_attempts {
767 let mut builder = hyper::Request::builder()
768 .method(hyper::Method::POST)
769 .header("content-type", "application/json");
770
771 if let Some(uri) = pinned_uri.as_ref() {
772 builder = builder.uri(uri.clone());
773 if let Some(host) = pinned_host_header.as_deref() {
774 builder = builder.header("host", host);
775 }
776 } else {
777 builder = builder.uri(url);
778 }
779
780 if let Some(ref auth) = config.authentication {
787 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
788 Some("Bearer")
789 } else if auth.scheme.eq_ignore_ascii_case("basic") {
790 Some("Basic")
791 } else {
792 None
793 };
794 match (canonical_scheme, auth.credentials.as_deref()) {
795 (Some(prefix), Some(credentials)) => {
796 builder =
797 builder.header("authorization", format!("{prefix} {credentials}"));
798 }
799 (Some(_), None) => {
800 trace_warn!(
801 scheme = auth.scheme.as_str(),
802 "authentication scheme has no credentials; no auth header set"
803 );
804 }
805 (None, _) => {
806 trace_warn!(
807 scheme = auth.scheme.as_str(),
808 "unknown authentication scheme; no auth header set"
809 );
810 }
811 }
812 }
813
814 if let Some(ref token) = config.token {
824 builder = builder.header("x-a2a-notification-token", token.as_str());
825 }
826
827 let req = builder
828 .body(Full::new(body_bytes.clone()))
829 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
830
831 let request_result =
832 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
833
834 match request_result {
835 Ok(Ok(resp)) if resp.status().is_success() => {
836 trace_debug!(url, "push notification delivered");
837 return Ok(());
838 }
839 Ok(Ok(resp)) => {
840 let status = resp.status();
841 let retryable = status.is_server_error()
847 || status == hyper::StatusCode::REQUEST_TIMEOUT
848 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
849 if !retryable {
850 trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
851 return Err(A2aError::internal(format!(
852 "push notification got non-retryable HTTP {status}"
853 )));
854 }
855 last_err = format!("push notification got HTTP {status}");
856 trace_warn!(url, attempt, status = %status, "push delivery failed");
857 }
858 Ok(Err(e)) => {
859 last_err = format!("push notification failed: {e}");
860 trace_warn!(url, attempt, error = %e, "push delivery error");
861 }
862 Err(_) => {
863 last_err = format!(
864 "push notification timed out after {}s",
865 self.request_timeout.as_secs()
866 );
867 trace_warn!(url, attempt, "push delivery timed out");
868 }
869 }
870
871 if attempt < self.retry_policy.max_attempts - 1 {
873 let delay = self
874 .retry_policy
875 .backoff
876 .get(attempt)
877 .or_else(|| self.retry_policy.backoff.last());
878 if let Some(delay) = delay {
879 tokio::time::sleep(*delay).await;
880 }
881 }
882 }
883
884 Err(A2aError::internal(last_err))
885 })
886 }
887}
888
889#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
897 fn push_retry_policy_with_max_attempts() {
898 let policy = PushRetryPolicy::default().with_max_attempts(5);
899 assert_eq!(policy.max_attempts, 5);
900 assert_eq!(policy.backoff.len(), 2);
902 }
903
904 #[test]
906 fn push_retry_policy_with_backoff() {
907 let backoff = vec![
908 std::time::Duration::from_millis(100),
909 std::time::Duration::from_millis(500),
910 std::time::Duration::from_secs(1),
911 ];
912 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
913 assert_eq!(policy.backoff, backoff);
914 assert_eq!(policy.max_attempts, 3);
916 }
917
918 #[test]
920 fn http_push_sender_with_retry_policy() {
921 let policy = PushRetryPolicy::default().with_max_attempts(10);
922 let sender = HttpPushSender::new().with_retry_policy(policy);
923 assert_eq!(sender.retry_policy.max_attempts, 10);
924 }
925
926 #[test]
928 fn rejects_url_without_host() {
929 assert!(validate_webhook_url("http:///path").is_err());
930 }
931
932 #[test]
934 fn http_push_sender_allow_private_urls() {
935 let sender = HttpPushSender::new().allow_private_urls();
936 assert!(sender.allow_private_urls);
937 }
938
939 #[test]
941 fn http_push_sender_default() {
942 let sender = HttpPushSender::default();
943 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
944 assert!(!sender.allow_private_urls);
945 }
946
947 #[test]
949 fn push_retry_policy_default() {
950 let policy = PushRetryPolicy::default();
951 assert_eq!(policy.max_attempts, 3);
952 assert_eq!(policy.backoff.len(), 2);
953 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
954 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
955 }
956
957 #[test]
958 fn rejects_loopback_ipv4() {
959 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
960 }
961
962 #[test]
963 fn rejects_private_10_range() {
964 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
965 }
966
967 #[test]
968 fn rejects_private_172_range() {
969 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
970 }
971
972 #[test]
973 fn rejects_private_192_168_range() {
974 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
975 }
976
977 #[test]
978 fn rejects_link_local() {
979 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
980 }
981
982 #[test]
983 fn parse_numeric_ipv4_matches_the_resolver() {
984 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
985 assert_eq!(
987 parse_numeric_ipv4("2852039166"),
988 Some(v4("169.254.169.254"))
989 ); assert_eq!(
991 parse_numeric_ipv4("0xA9FEA9FE"),
992 Some(v4("169.254.169.254"))
993 ); assert_eq!(
995 parse_numeric_ipv4("0xa9fea9fe"),
996 Some(v4("169.254.169.254"))
997 ); assert_eq!(
999 parse_numeric_ipv4("0251.0376.0251.0376"),
1000 Some(v4("169.254.169.254")) );
1002 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")));
1009 assert_eq!(parse_numeric_ipv4("example.com"), None);
1011 assert_eq!(parse_numeric_ipv4("printer.local"), None);
1012 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); }
1018
1019 #[test]
1020 fn rejects_decimal_integer_metadata() {
1021 assert!(validate_webhook_url("http://2852039166/latest/meta-data").is_err());
1024 }
1025
1026 #[test]
1027 fn rejects_hex_integer_metadata() {
1028 assert!(validate_webhook_url("http://0xA9FEA9FE/latest").is_err());
1029 }
1030
1031 #[test]
1032 fn rejects_octal_dotted_metadata() {
1033 assert!(validate_webhook_url("http://0251.0376.0251.0376/latest").is_err());
1034 }
1035
1036 #[test]
1037 fn rejects_packed_short_form_loopback() {
1038 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());
1041 }
1042
1043 #[test]
1044 fn accepts_public_numeric_ip() {
1045 assert!(validate_webhook_url("http://134744072/webhook").is_ok());
1048 }
1049
1050 #[test]
1051 fn max_delivery_duration_matches_the_retry_loop_it_describes() {
1052 use std::time::Duration;
1053
1054 let sender = HttpPushSender::new();
1056 assert_eq!(
1057 sender.max_delivery_duration(),
1058 Some(Duration::from_secs(30 + 1 + 30 + 2 + 30)),
1059 "the default schedule is 93 seconds"
1060 );
1061
1062 let one = HttpPushSender::with_timeout(Duration::from_secs(7))
1064 .with_retry_policy(PushRetryPolicy::default().with_max_attempts(1));
1065 assert_eq!(one.max_delivery_duration(), Some(Duration::from_secs(7)));
1066
1067 let many = HttpPushSender::with_timeout(Duration::from_secs(1)).with_retry_policy(
1070 PushRetryPolicy::default().with_max_attempts(5), );
1072 assert_eq!(
1073 many.max_delivery_duration(),
1074 Some(Duration::from_secs(5 + 1 + 2 + 2 + 2)),
1075 "backoff falls back to its last entry, as `send` does"
1076 );
1077 }
1078
1079 #[test]
1086 fn the_default_schedule_does_not_fit_the_default_handler_bound() {
1087 use crate::handler::HandlerLimits;
1088
1089 let wanted = HttpPushSender::new()
1090 .max_delivery_duration()
1091 .expect("HttpPushSender reports its schedule");
1092 let allowed = HandlerLimits::default().push_delivery_timeout;
1093
1094 assert!(
1095 wanted > allowed,
1096 "if these no longer contradict, the fix landed — update this test \
1097 and the arithmetic in HandlerLimits::push_delivery_timeout's docs. \
1098 sender wants {wanted:?}, handler allows {allowed:?}"
1099 );
1100
1101 let policy = PushRetryPolicy::default();
1105 let request = std::time::Duration::from_secs(30);
1106 let mut spent = std::time::Duration::ZERO;
1107 let mut attempts = 0_usize;
1108 for i in 0..policy.max_attempts {
1109 if spent >= allowed {
1110 break;
1111 }
1112 attempts += 1;
1113 spent += request;
1114 if let Some(d) = policy.backoff.get(i).or_else(|| policy.backoff.last()) {
1115 spent += *d;
1116 }
1117 }
1118 assert_eq!(
1119 attempts, 1,
1120 "at the shipped defaults one attempt of {} runs",
1121 policy.max_attempts
1122 );
1123 }
1124
1125 #[test]
1126 fn rejects_localhost() {
1127 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
1128 }
1129
1130 #[test]
1131 fn rejects_dot_local() {
1132 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
1133 }
1134
1135 #[test]
1136 fn rejects_dot_internal() {
1137 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
1138 }
1139
1140 #[test]
1141 fn rejects_ipv6_loopback() {
1142 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
1143 }
1144
1145 #[test]
1152 fn rejects_ipv4_mapped_loopback() {
1153 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
1154 }
1155
1156 #[test]
1157 fn rejects_ipv4_mapped_metadata() {
1158 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
1159 }
1160
1161 #[test]
1162 fn rejects_ipv4_mapped_private() {
1163 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
1164 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
1165 }
1166
1167 #[test]
1168 fn rejects_ipv4_compatible_loopback() {
1169 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
1171 }
1172
1173 #[test]
1174 fn rejects_nat64_wellknown_prefix_to_private() {
1175 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
1177 }
1178
1179 #[test]
1180 fn accepts_ipv4_mapped_public() {
1181 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
1183 }
1184
1185 #[test]
1193 fn is_private_v4_cgnat_boundary() {
1194 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
1196 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()));
1202 }
1203
1204 #[test]
1205 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
1206 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
1207 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
1208
1209 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
1211 assert_eq!(
1212 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
1213 v4("169.254.169.254")
1214 );
1215 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
1218 assert_eq!(embedded_ipv4(v6("::")), None);
1219 assert_eq!(embedded_ipv4(v6("::1")), None);
1220 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
1222 }
1223
1224 #[test]
1225 fn accepts_public_url() {
1226 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
1227 }
1228
1229 #[test]
1230 fn accepts_public_ip() {
1231 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
1232 }
1233
1234 #[test]
1235 fn rejects_header_with_crlf() {
1236 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
1237 }
1238
1239 #[test]
1240 fn rejects_header_with_cr() {
1241 assert!(validate_header_value("token\rvalue", "test").is_err());
1242 }
1243
1244 #[test]
1245 fn rejects_header_with_lf() {
1246 assert!(validate_header_value("token\nvalue", "test").is_err());
1247 }
1248
1249 #[test]
1250 fn accepts_clean_header_value() {
1251 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
1252 }
1253
1254 #[test]
1255 fn rejects_url_without_scheme() {
1256 assert!(validate_webhook_url("example.com/webhook").is_err());
1257 }
1258
1259 #[test]
1260 fn rejects_ftp_scheme() {
1261 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
1262 }
1263
1264 #[test]
1265 fn rejects_file_scheme() {
1266 assert!(validate_webhook_url("file:///etc/passwd").is_err());
1267 }
1268
1269 #[test]
1270 fn accepts_http_scheme() {
1271 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
1272 }
1273
1274 #[test]
1275 fn rejects_cgnat_range() {
1276 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
1277 }
1278
1279 #[test]
1280 fn rejects_unspecified_ipv4() {
1281 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
1282 }
1283
1284 #[test]
1285 fn rejects_ipv6_unique_local() {
1286 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1287 }
1288
1289 #[test]
1290 fn rejects_ipv6_link_local() {
1291 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1292 }
1293
1294 #[tokio::test]
1297 async fn dns_rejects_loopback_ip_literal() {
1298 let result = validate_webhook_url_with_dns("http://127.0.0.1:8080/webhook").await;
1300 assert!(result.is_err(), "loopback IP should be rejected");
1301 }
1302
1303 #[tokio::test]
1304 async fn dns_rejects_private_ip_literal() {
1305 let result = validate_webhook_url_with_dns("http://10.0.0.1/webhook").await;
1306 assert!(result.is_err(), "private IP should be rejected");
1307 }
1308
1309 #[tokio::test]
1310 async fn dns_rejects_localhost_hostname() {
1311 let result = validate_webhook_url_with_dns("http://localhost:8080/webhook").await;
1313 assert!(result.is_err(), "localhost should be rejected");
1314 }
1315
1316 #[tokio::test]
1317 async fn dns_rejects_invalid_scheme() {
1318 let result = validate_webhook_url_with_dns("ftp://example.com/webhook").await;
1319 assert!(result.is_err(), "ftp scheme should be rejected");
1320 }
1321
1322 #[tokio::test]
1323 async fn dns_rejects_missing_host() {
1324 let result = validate_webhook_url_with_dns("http:///path").await;
1325 assert!(result.is_err(), "missing host should be rejected");
1326 }
1327
1328 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1329 async fn dns_rejects_unresolvable_hostname() {
1330 let (tx, rx) = tokio::sync::oneshot::channel();
1333 std::thread::spawn(move || {
1334 let rt = tokio::runtime::Builder::new_current_thread()
1335 .enable_all()
1336 .build()
1337 .unwrap();
1338 let result = rt.block_on(validate_webhook_url_with_dns(
1339 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1340 ));
1341 let _ = tx.send(result);
1342 });
1343 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1344 Ok(Ok(result)) => {
1345 assert!(result.is_err(), "unresolvable hostname should be rejected");
1346 }
1347 Ok(Err(_)) => panic!("sender dropped without sending"),
1348 Err(_elapsed) => {
1349 }
1351 }
1352 }
1353
1354 #[tokio::test]
1355 async fn dns_accepts_ip_literal_public() {
1356 let result = validate_webhook_url_with_dns("https://203.0.113.1/webhook").await;
1359 assert!(
1360 matches!(result, Ok(None)),
1361 "public IP literal should be accepted with no pinning (got {result:?})",
1362 );
1363 }
1364
1365 #[test]
1368 fn rewrite_uri_preserves_scheme_path_and_query() {
1369 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1370 let rewritten =
1371 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1372 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1373 }
1374
1375 #[test]
1376 fn rewrite_uri_uses_ipv6_brackets() {
1377 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1378 let rewritten =
1379 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1380 assert!(
1382 rewritten.to_string().contains("[2001:db8::1]:443"),
1383 "IPv6 literal should be bracketed: {rewritten}",
1384 );
1385 }
1386
1387 #[test]
1388 fn rewrite_uri_default_path_when_missing() {
1389 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1390 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1391 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1392 }
1393
1394 #[test]
1395 fn host_header_includes_port_when_present() {
1396 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1397 assert_eq!(host, "example.com:8080");
1398 }
1399
1400 #[test]
1401 fn host_header_omits_default_port() {
1402 let host = host_header_from_url("https://example.com/webhook").unwrap();
1403 assert_eq!(host, "example.com");
1404 }
1405
1406 #[test]
1407 fn host_header_from_url_rejects_missing_host() {
1408 let result = host_header_from_url("http:///path");
1409 assert!(result.is_err());
1410 }
1411
1412 #[test]
1413 fn pin_target_pins_http_but_not_https() {
1414 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1415 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1417 assert_eq!(pin_target(true, Some(addr)), None);
1420 assert_eq!(pin_target(false, None), None);
1422 assert_eq!(pin_target(true, None), None);
1423 }
1424
1425 fn dummy_event() -> StreamResponse {
1428 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1429 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1430 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1431 task_id: TaskId::new("t1"),
1432 context_id: ContextId::new("c1"),
1433 status: TaskStatus::with_timestamp(TaskState::Working),
1434 metadata: None,
1435 })
1436 }
1437
1438 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1439 TaskPushNotificationConfig {
1440 tenant: None,
1441 id: Some("cfg".to_owned()),
1442 task_id: Some("t1".to_owned()),
1443 url: url.to_owned(),
1444 token: None,
1445 authentication: None,
1446 }
1447 }
1448
1449 #[cfg(not(feature = "tls-rustls"))]
1452 #[tokio::test]
1453 async fn https_without_tls_feature_fails_fast() {
1454 let sender = HttpPushSender::new();
1455 let event = dummy_event();
1456 let config = dummy_config("https://example.com/webhook");
1457 let err = sender
1458 .send(&config.url, &event, &config)
1459 .await
1460 .expect_err("https must fail fast without the tls-rustls feature");
1461 assert!(
1462 err.to_string().contains("HTTP only"),
1463 "expected the HTTP-only error, got: {err}"
1464 );
1465 }
1466
1467 #[cfg(feature = "tls-rustls")]
1471 #[tokio::test]
1472 async fn https_with_tls_feature_still_enforces_ssrf() {
1473 let sender = HttpPushSender::new();
1474 let event = dummy_event();
1475 let config = dummy_config("https://127.0.0.1:8443/webhook");
1476 let err = sender
1477 .send(&config.url, &event, &config)
1478 .await
1479 .expect_err("https to a loopback address must be rejected by SSRF");
1480 let msg = err.to_string();
1481 assert!(
1482 msg.contains("private/loopback") || msg.contains("loopback"),
1483 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1484 );
1485 }
1486}
1487
1488#[cfg(test)]
1489mod port_tests {
1490 use super::webhook_port;
1491
1492 #[test]
1496 fn scheme_defaults_are_not_swapped() {
1497 let port = |u: &str| webhook_port(&u.parse::<hyper::Uri>().expect("uri"));
1498
1499 assert_eq!(
1500 port("https://example.com/hook"),
1501 443,
1502 "https defaults to 443"
1503 );
1504 assert_eq!(port("http://example.com/hook"), 80, "http defaults to 80");
1505 assert_eq!(port("https://example.com:8443/hook"), 8443);
1508 assert_eq!(port("http://example.com:8080/hook"), 8080);
1509 }
1510}