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()
779 .method(hyper::Method::POST)
780 .header("content-type", a2a_protocol_types::A2A_CONTENT_TYPE);
781
782 if let Some(uri) = pinned_uri.as_ref() {
783 builder = builder.uri(uri.clone());
784 if let Some(host) = pinned_host_header.as_deref() {
785 builder = builder.header("host", host);
786 }
787 } else {
788 builder = builder.uri(url);
789 }
790
791 if let Some(ref auth) = config.authentication {
798 let canonical_scheme = if auth.scheme.eq_ignore_ascii_case("bearer") {
799 Some("Bearer")
800 } else if auth.scheme.eq_ignore_ascii_case("basic") {
801 Some("Basic")
802 } else {
803 None
804 };
805 match (canonical_scheme, auth.credentials.as_deref()) {
806 (Some(prefix), Some(credentials)) => {
807 builder =
808 builder.header("authorization", format!("{prefix} {credentials}"));
809 }
810 (Some(_), None) => {
811 trace_warn!(
812 scheme = auth.scheme.as_str(),
813 "authentication scheme has no credentials; no auth header set"
814 );
815 }
816 (None, _) => {
817 trace_warn!(
818 scheme = auth.scheme.as_str(),
819 "unknown authentication scheme; no auth header set"
820 );
821 }
822 }
823 }
824
825 if let Some(ref token) = config.token {
835 builder = builder.header("x-a2a-notification-token", token.as_str());
836 }
837
838 let req = builder
839 .body(Full::new(body_bytes.clone()))
840 .map_err(|e| A2aError::internal(format!("push request build: {e}")))?;
841
842 let request_result =
843 tokio::time::timeout(self.request_timeout, self.client.request(req)).await;
844
845 match request_result {
846 Ok(Ok(resp)) if resp.status().is_success() => {
847 trace_debug!(url, "push notification delivered");
848 return Ok(());
849 }
850 Ok(Ok(resp)) => {
851 let status = resp.status();
852 let retryable = status.is_server_error()
858 || status == hyper::StatusCode::REQUEST_TIMEOUT
859 || status == hyper::StatusCode::TOO_MANY_REQUESTS;
860 if !retryable {
861 trace_warn!(url, attempt, status = %status, "push delivery rejected; not retrying");
862 return Err(A2aError::internal(format!(
863 "push notification got non-retryable HTTP {status}"
864 )));
865 }
866 last_err = format!("push notification got HTTP {status}");
867 trace_warn!(url, attempt, status = %status, "push delivery failed");
868 }
869 Ok(Err(e)) => {
870 last_err = format!("push notification failed: {e}");
871 trace_warn!(url, attempt, error = %e, "push delivery error");
872 }
873 Err(_) => {
874 last_err = format!(
875 "push notification timed out after {}s",
876 self.request_timeout.as_secs()
877 );
878 trace_warn!(url, attempt, "push delivery timed out");
879 }
880 }
881
882 if attempt < self.retry_policy.max_attempts - 1 {
884 let delay = self
885 .retry_policy
886 .backoff
887 .get(attempt)
888 .or_else(|| self.retry_policy.backoff.last());
889 if let Some(delay) = delay {
890 tokio::time::sleep(*delay).await;
891 }
892 }
893 }
894
895 Err(A2aError::internal(last_err))
896 })
897 }
898}
899
900#[cfg(test)]
903mod tests {
904 use super::*;
905
906 #[test]
908 fn push_retry_policy_with_max_attempts() {
909 let policy = PushRetryPolicy::default().with_max_attempts(5);
910 assert_eq!(policy.max_attempts, 5);
911 assert_eq!(policy.backoff.len(), 2);
913 }
914
915 #[test]
917 fn push_retry_policy_with_backoff() {
918 let backoff = vec![
919 std::time::Duration::from_millis(100),
920 std::time::Duration::from_millis(500),
921 std::time::Duration::from_secs(1),
922 ];
923 let policy = PushRetryPolicy::default().with_backoff(backoff.clone());
924 assert_eq!(policy.backoff, backoff);
925 assert_eq!(policy.max_attempts, 3);
927 }
928
929 #[test]
931 fn http_push_sender_with_retry_policy() {
932 let policy = PushRetryPolicy::default().with_max_attempts(10);
933 let sender = HttpPushSender::new().with_retry_policy(policy);
934 assert_eq!(sender.retry_policy.max_attempts, 10);
935 }
936
937 #[test]
939 fn rejects_url_without_host() {
940 assert!(validate_webhook_url("http:///path").is_err());
941 }
942
943 #[test]
945 fn http_push_sender_allow_private_urls() {
946 let sender = HttpPushSender::new().allow_private_urls();
947 assert!(sender.allow_private_urls);
948 }
949
950 #[test]
952 fn http_push_sender_default() {
953 let sender = HttpPushSender::default();
954 assert_eq!(sender.request_timeout, DEFAULT_PUSH_REQUEST_TIMEOUT);
955 assert!(!sender.allow_private_urls);
956 }
957
958 #[test]
960 fn push_retry_policy_default() {
961 let policy = PushRetryPolicy::default();
962 assert_eq!(policy.max_attempts, 3);
963 assert_eq!(policy.backoff.len(), 2);
964 assert_eq!(policy.backoff[0], std::time::Duration::from_secs(1));
965 assert_eq!(policy.backoff[1], std::time::Duration::from_secs(2));
966 }
967
968 #[test]
969 fn rejects_loopback_ipv4() {
970 assert!(validate_webhook_url("http://127.0.0.1:8080/webhook").is_err());
971 }
972
973 #[test]
974 fn rejects_private_10_range() {
975 assert!(validate_webhook_url("http://10.0.0.1/webhook").is_err());
976 }
977
978 #[test]
979 fn rejects_private_172_range() {
980 assert!(validate_webhook_url("http://172.16.0.1/webhook").is_err());
981 }
982
983 #[test]
984 fn rejects_private_192_168_range() {
985 assert!(validate_webhook_url("http://192.168.1.1/webhook").is_err());
986 }
987
988 #[test]
989 fn rejects_link_local() {
990 assert!(validate_webhook_url("http://169.254.169.254/latest").is_err());
991 }
992
993 #[test]
994 fn parse_numeric_ipv4_matches_the_resolver() {
995 let v4 = |s: &str| s.parse::<Ipv4Addr>().unwrap();
996 assert_eq!(
998 parse_numeric_ipv4("2852039166"),
999 Some(v4("169.254.169.254"))
1000 ); assert_eq!(
1002 parse_numeric_ipv4("0xA9FEA9FE"),
1003 Some(v4("169.254.169.254"))
1004 ); assert_eq!(
1006 parse_numeric_ipv4("0xa9fea9fe"),
1007 Some(v4("169.254.169.254"))
1008 ); assert_eq!(
1010 parse_numeric_ipv4("0251.0376.0251.0376"),
1011 Some(v4("169.254.169.254")) );
1013 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")));
1020 assert_eq!(parse_numeric_ipv4("example.com"), None);
1022 assert_eq!(parse_numeric_ipv4("printer.local"), None);
1023 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); }
1029
1030 #[test]
1031 fn rejects_decimal_integer_metadata() {
1032 assert!(validate_webhook_url("http://2852039166/latest/meta-data").is_err());
1035 }
1036
1037 #[test]
1038 fn rejects_hex_integer_metadata() {
1039 assert!(validate_webhook_url("http://0xA9FEA9FE/latest").is_err());
1040 }
1041
1042 #[test]
1043 fn rejects_octal_dotted_metadata() {
1044 assert!(validate_webhook_url("http://0251.0376.0251.0376/latest").is_err());
1045 }
1046
1047 #[test]
1048 fn rejects_packed_short_form_loopback() {
1049 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());
1052 }
1053
1054 #[test]
1055 fn accepts_public_numeric_ip() {
1056 assert!(validate_webhook_url("http://134744072/webhook").is_ok());
1059 }
1060
1061 #[test]
1062 fn max_delivery_duration_matches_the_retry_loop_it_describes() {
1063 use std::time::Duration;
1064
1065 let sender = HttpPushSender::new();
1067 assert_eq!(
1068 sender.max_delivery_duration(),
1069 Some(Duration::from_secs(30 + 1 + 30 + 2 + 30)),
1070 "the default schedule is 93 seconds"
1071 );
1072
1073 let one = HttpPushSender::with_timeout(Duration::from_secs(7))
1075 .with_retry_policy(PushRetryPolicy::default().with_max_attempts(1));
1076 assert_eq!(one.max_delivery_duration(), Some(Duration::from_secs(7)));
1077
1078 let many = HttpPushSender::with_timeout(Duration::from_secs(1)).with_retry_policy(
1081 PushRetryPolicy::default().with_max_attempts(5), );
1083 assert_eq!(
1084 many.max_delivery_duration(),
1085 Some(Duration::from_secs(5 + 1 + 2 + 2 + 2)),
1086 "backoff falls back to its last entry, as `send` does"
1087 );
1088 }
1089
1090 #[test]
1097 fn the_default_schedule_does_not_fit_the_default_handler_bound() {
1098 use crate::handler::HandlerLimits;
1099
1100 let wanted = HttpPushSender::new()
1101 .max_delivery_duration()
1102 .expect("HttpPushSender reports its schedule");
1103 let allowed = HandlerLimits::default().push_delivery_timeout;
1104
1105 assert!(
1106 wanted > allowed,
1107 "if these no longer contradict, the fix landed — update this test \
1108 and the arithmetic in HandlerLimits::push_delivery_timeout's docs. \
1109 sender wants {wanted:?}, handler allows {allowed:?}"
1110 );
1111
1112 let policy = PushRetryPolicy::default();
1116 let request = std::time::Duration::from_secs(30);
1117 let mut spent = std::time::Duration::ZERO;
1118 let mut attempts = 0_usize;
1119 for i in 0..policy.max_attempts {
1120 if spent >= allowed {
1121 break;
1122 }
1123 attempts += 1;
1124 spent += request;
1125 if let Some(d) = policy.backoff.get(i).or_else(|| policy.backoff.last()) {
1126 spent += *d;
1127 }
1128 }
1129 assert_eq!(
1130 attempts, 1,
1131 "at the shipped defaults one attempt of {} runs",
1132 policy.max_attempts
1133 );
1134 }
1135
1136 #[test]
1137 fn rejects_localhost() {
1138 assert!(validate_webhook_url("http://localhost:8080/webhook").is_err());
1139 }
1140
1141 #[test]
1142 fn rejects_dot_local() {
1143 assert!(validate_webhook_url("http://myservice.local/webhook").is_err());
1144 }
1145
1146 #[test]
1147 fn rejects_dot_internal() {
1148 assert!(validate_webhook_url("http://metadata.internal/webhook").is_err());
1149 }
1150
1151 #[test]
1152 fn rejects_ipv6_loopback() {
1153 assert!(validate_webhook_url("http://[::1]:8080/webhook").is_err());
1154 }
1155
1156 #[test]
1163 fn rejects_ipv4_mapped_loopback() {
1164 assert!(validate_webhook_url("http://[::ffff:127.0.0.1]:8080/webhook").is_err());
1165 }
1166
1167 #[test]
1168 fn rejects_ipv4_mapped_metadata() {
1169 assert!(validate_webhook_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err());
1170 }
1171
1172 #[test]
1173 fn rejects_ipv4_mapped_private() {
1174 assert!(validate_webhook_url("http://[::ffff:10.0.0.1]/webhook").is_err());
1175 assert!(validate_webhook_url("http://[::ffff:192.168.1.1]/webhook").is_err());
1176 }
1177
1178 #[test]
1179 fn rejects_ipv4_compatible_loopback() {
1180 assert!(validate_webhook_url("http://[::127.0.0.1]/webhook").is_err());
1182 }
1183
1184 #[test]
1185 fn rejects_nat64_wellknown_prefix_to_private() {
1186 assert!(validate_webhook_url("http://[64:ff9b::a9fe:a9fe]/latest").is_err());
1188 }
1189
1190 #[test]
1191 fn accepts_ipv4_mapped_public() {
1192 assert!(validate_webhook_url("http://[::ffff:203.0.113.1]/webhook").is_ok());
1194 }
1195
1196 #[test]
1204 fn is_private_v4_cgnat_boundary() {
1205 assert!(is_private_v4("100.64.0.1".parse().unwrap()));
1207 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()));
1213 }
1214
1215 #[test]
1216 fn embedded_ipv4_recovers_only_the_v4_bearing_forms() {
1217 let v6 = |s: &str| s.parse::<std::net::Ipv6Addr>().unwrap();
1218 let v4 = |s: &str| Some(s.parse::<std::net::Ipv4Addr>().unwrap());
1219
1220 assert_eq!(embedded_ipv4(v6("::ffff:127.0.0.1")), v4("127.0.0.1"));
1222 assert_eq!(
1223 embedded_ipv4(v6("64:ff9b::a9fe:a9fe")),
1224 v4("169.254.169.254")
1225 );
1226 assert_eq!(embedded_ipv4(v6("::2")), v4("0.0.0.2"));
1229 assert_eq!(embedded_ipv4(v6("::")), None);
1230 assert_eq!(embedded_ipv4(v6("::1")), None);
1231 assert_eq!(embedded_ipv4(v6("2606:4700::1111")), None);
1233 }
1234
1235 #[test]
1236 fn accepts_public_url() {
1237 assert!(validate_webhook_url("https://example.com/webhook").is_ok());
1238 }
1239
1240 #[test]
1241 fn accepts_public_ip() {
1242 assert!(validate_webhook_url("https://203.0.113.1/webhook").is_ok());
1243 }
1244
1245 #[test]
1246 fn rejects_header_with_crlf() {
1247 assert!(validate_header_value("token\r\nX-Injected: value", "test").is_err());
1248 }
1249
1250 #[test]
1251 fn rejects_header_with_cr() {
1252 assert!(validate_header_value("token\rvalue", "test").is_err());
1253 }
1254
1255 #[test]
1256 fn rejects_header_with_lf() {
1257 assert!(validate_header_value("token\nvalue", "test").is_err());
1258 }
1259
1260 #[test]
1261 fn accepts_clean_header_value() {
1262 assert!(validate_header_value("Bearer abc123+/=", "test").is_ok());
1263 }
1264
1265 #[test]
1266 fn rejects_url_without_scheme() {
1267 assert!(validate_webhook_url("example.com/webhook").is_err());
1268 }
1269
1270 #[test]
1271 fn rejects_ftp_scheme() {
1272 assert!(validate_webhook_url("ftp://example.com/webhook").is_err());
1273 }
1274
1275 #[test]
1276 fn rejects_file_scheme() {
1277 assert!(validate_webhook_url("file:///etc/passwd").is_err());
1278 }
1279
1280 #[test]
1281 fn accepts_http_scheme() {
1282 assert!(validate_webhook_url("http://example.com/webhook").is_ok());
1283 }
1284
1285 #[test]
1286 fn rejects_cgnat_range() {
1287 assert!(validate_webhook_url("http://100.64.0.1/webhook").is_err());
1288 }
1289
1290 #[test]
1291 fn rejects_unspecified_ipv4() {
1292 assert!(validate_webhook_url("http://0.0.0.0/webhook").is_err());
1293 }
1294
1295 #[test]
1296 fn rejects_ipv6_unique_local() {
1297 assert!(validate_webhook_url("http://[fc00::1]:8080/webhook").is_err());
1298 }
1299
1300 #[test]
1301 fn rejects_ipv6_link_local() {
1302 assert!(validate_webhook_url("http://[fe80::1]:8080/webhook").is_err());
1303 }
1304
1305 #[tokio::test]
1308 async fn dns_rejects_loopback_ip_literal() {
1309 let result = validate_webhook_url_with_dns("http://127.0.0.1:8080/webhook").await;
1311 assert!(result.is_err(), "loopback IP should be rejected");
1312 }
1313
1314 #[tokio::test]
1315 async fn dns_rejects_private_ip_literal() {
1316 let result = validate_webhook_url_with_dns("http://10.0.0.1/webhook").await;
1317 assert!(result.is_err(), "private IP should be rejected");
1318 }
1319
1320 #[tokio::test]
1321 async fn dns_rejects_localhost_hostname() {
1322 let result = validate_webhook_url_with_dns("http://localhost:8080/webhook").await;
1324 assert!(result.is_err(), "localhost should be rejected");
1325 }
1326
1327 #[tokio::test]
1328 async fn dns_rejects_invalid_scheme() {
1329 let result = validate_webhook_url_with_dns("ftp://example.com/webhook").await;
1330 assert!(result.is_err(), "ftp scheme should be rejected");
1331 }
1332
1333 #[tokio::test]
1334 async fn dns_rejects_missing_host() {
1335 let result = validate_webhook_url_with_dns("http:///path").await;
1336 assert!(result.is_err(), "missing host should be rejected");
1337 }
1338
1339 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1340 async fn dns_rejects_unresolvable_hostname() {
1341 let (tx, rx) = tokio::sync::oneshot::channel();
1344 std::thread::spawn(move || {
1345 let rt = tokio::runtime::Builder::new_current_thread()
1346 .enable_all()
1347 .build()
1348 .unwrap();
1349 let result = rt.block_on(validate_webhook_url_with_dns(
1350 "https://this-hostname-definitely-does-not-exist-a2a-test.invalid/webhook",
1351 ));
1352 let _ = tx.send(result);
1353 });
1354 match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
1355 Ok(Ok(result)) => {
1356 assert!(result.is_err(), "unresolvable hostname should be rejected");
1357 }
1358 Ok(Err(_)) => panic!("sender dropped without sending"),
1359 Err(_elapsed) => {
1360 }
1362 }
1363 }
1364
1365 #[tokio::test]
1366 async fn dns_accepts_ip_literal_public() {
1367 let result = validate_webhook_url_with_dns("https://203.0.113.1/webhook").await;
1370 assert!(
1371 matches!(result, Ok(None)),
1372 "public IP literal should be accepted with no pinning (got {result:?})",
1373 );
1374 }
1375
1376 #[test]
1379 fn rewrite_uri_preserves_scheme_path_and_query() {
1380 let pinned: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1381 let rewritten =
1382 rewrite_uri_with_pinned_addr("http://example.com:8080/webhook?x=1", pinned).unwrap();
1383 assert_eq!(rewritten.to_string(), "http://203.0.113.1:8080/webhook?x=1",);
1384 }
1385
1386 #[test]
1387 fn rewrite_uri_uses_ipv6_brackets() {
1388 let pinned: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
1389 let rewritten =
1390 rewrite_uri_with_pinned_addr("https://example.com/webhook", pinned).unwrap();
1391 assert!(
1393 rewritten.to_string().contains("[2001:db8::1]:443"),
1394 "IPv6 literal should be bracketed: {rewritten}",
1395 );
1396 }
1397
1398 #[test]
1399 fn rewrite_uri_default_path_when_missing() {
1400 let pinned: SocketAddr = "203.0.113.1:80".parse().unwrap();
1401 let rewritten = rewrite_uri_with_pinned_addr("http://example.com", pinned).unwrap();
1402 assert_eq!(rewritten.to_string(), "http://203.0.113.1:80/");
1403 }
1404
1405 #[test]
1406 fn host_header_includes_port_when_present() {
1407 let host = host_header_from_url("http://example.com:8080/webhook").unwrap();
1408 assert_eq!(host, "example.com:8080");
1409 }
1410
1411 #[test]
1412 fn host_header_omits_default_port() {
1413 let host = host_header_from_url("https://example.com/webhook").unwrap();
1414 assert_eq!(host, "example.com");
1415 }
1416
1417 #[test]
1418 fn host_header_from_url_rejects_missing_host() {
1419 let result = host_header_from_url("http:///path");
1420 assert!(result.is_err());
1421 }
1422
1423 #[test]
1424 fn pin_target_pins_http_but_not_https() {
1425 let addr: SocketAddr = "203.0.113.1:8080".parse().unwrap();
1426 assert_eq!(pin_target(false, Some(addr)), Some(addr));
1428 assert_eq!(pin_target(true, Some(addr)), None);
1431 assert_eq!(pin_target(false, None), None);
1433 assert_eq!(pin_target(true, None), None);
1434 }
1435
1436 fn dummy_event() -> StreamResponse {
1439 use a2a_protocol_types::events::TaskStatusUpdateEvent;
1440 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
1441 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1442 task_id: TaskId::new("t1"),
1443 context_id: ContextId::new("c1"),
1444 status: TaskStatus::with_timestamp(TaskState::Working),
1445 metadata: None,
1446 })
1447 }
1448
1449 fn dummy_config(url: &str) -> TaskPushNotificationConfig {
1450 TaskPushNotificationConfig {
1451 tenant: None,
1452 id: Some("cfg".to_owned()),
1453 task_id: Some("t1".to_owned()),
1454 url: url.to_owned(),
1455 token: None,
1456 authentication: None,
1457 }
1458 }
1459
1460 #[cfg(not(feature = "tls-rustls"))]
1463 #[tokio::test]
1464 async fn https_without_tls_feature_fails_fast() {
1465 let sender = HttpPushSender::new();
1466 let event = dummy_event();
1467 let config = dummy_config("https://example.com/webhook");
1468 let err = sender
1469 .send(&config.url, &event, &config)
1470 .await
1471 .expect_err("https must fail fast without the tls-rustls feature");
1472 assert!(
1473 err.to_string().contains("HTTP only"),
1474 "expected the HTTP-only error, got: {err}"
1475 );
1476 }
1477
1478 #[cfg(feature = "tls-rustls")]
1482 #[tokio::test]
1483 async fn https_with_tls_feature_still_enforces_ssrf() {
1484 let sender = HttpPushSender::new();
1485 let event = dummy_event();
1486 let config = dummy_config("https://127.0.0.1:8443/webhook");
1487 let err = sender
1488 .send(&config.url, &event, &config)
1489 .await
1490 .expect_err("https to a loopback address must be rejected by SSRF");
1491 let msg = err.to_string();
1492 assert!(
1493 msg.contains("private/loopback") || msg.contains("loopback"),
1494 "expected an SSRF rejection (not the HTTP-only error), got: {msg}"
1495 );
1496 }
1497}
1498
1499#[cfg(test)]
1500mod port_tests {
1501 use super::webhook_port;
1502
1503 #[test]
1507 fn scheme_defaults_are_not_swapped() {
1508 let port = |u: &str| webhook_port(&u.parse::<hyper::Uri>().expect("uri"));
1509
1510 assert_eq!(
1511 port("https://example.com/hook"),
1512 443,
1513 "https defaults to 443"
1514 );
1515 assert_eq!(port("http://example.com/hook"), 80, "http defaults to 80");
1516 assert_eq!(port("https://example.com:8443/hook"), 8443);
1519 assert_eq!(port("http://example.com:8080/hook"), 8080);
1520 }
1521}