1use a2a::*;
4use std::time::Duration;
5
6pub struct HttpPushSender {
10 client: reqwest::Client,
11 fail_on_error: bool,
12 validate_urls: bool,
13}
14
15pub struct HttpPushSenderConfig {
17 pub timeout: Duration,
19 pub fail_on_error: bool,
21 pub validate_urls: bool,
26}
27
28impl Default for HttpPushSenderConfig {
29 fn default() -> Self {
30 HttpPushSenderConfig {
31 timeout: Duration::from_secs(30),
32 fail_on_error: false,
33 validate_urls: true,
34 }
35 }
36}
37
38impl HttpPushSender {
39 pub fn new(config: Option<HttpPushSenderConfig>) -> Self {
40 let config = config.unwrap_or_default();
41 let client = reqwest::Client::builder()
42 .timeout(config.timeout)
43 .build()
44 .expect("failed to create HTTP client");
45 HttpPushSender {
46 client,
47 fail_on_error: config.fail_on_error,
48 validate_urls: config.validate_urls,
49 }
50 }
51
52 pub async fn send_push(
54 &self,
55 config: &TaskPushNotificationConfig,
56 event: StreamResponse,
57 ) -> Result<(), A2AError> {
58 if self.validate_urls {
59 validate_push_url(&config.url)?;
60 }
61
62 if let Some(ref token) = config.token {
65 if token.contains('\r') || token.contains('\n') {
66 return Err(A2AError::invalid_params(
67 "push notification token must not contain CR/LF",
68 ));
69 }
70 }
71 if let Some(ref auth) = config.authentication {
72 if let Some(ref creds) = auth.credentials {
73 if creds.contains('\r') || creds.contains('\n') {
74 return Err(A2AError::invalid_params(
75 "push credentials must not contain CR/LF",
76 ));
77 }
78 }
79 }
80
81 let body = match serde_json::to_vec(&event) {
82 Ok(b) => b,
83 Err(e) => return self.handle_error(format!("failed to serialize event: {e}")),
84 };
85
86 let mut request = self
87 .client
88 .post(&config.url)
89 .header("Content-Type", "application/json")
90 .body(body);
91
92 if let Some(ref token) = config.token {
93 request = request.header("A2A-Notification-Token", token);
94 }
95
96 if let Some(ref auth) = config.authentication {
97 if let Some(ref creds) = auth.credentials {
98 match auth.scheme.to_lowercase().as_str() {
99 "bearer" => {
100 request = request.header("Authorization", format!("Bearer {creds}"));
101 }
102 "basic" => {
103 request = request.header("Authorization", format!("Basic {creds}"));
104 }
105 _ => {}
106 }
107 }
108 }
109
110 match request.send().await {
111 Ok(resp) => {
112 if !resp.status().is_success() {
113 return self
114 .handle_error(format!("push endpoint returned status: {}", resp.status()));
115 }
116 Ok(())
117 }
118 Err(e) => self.handle_error(format!("failed to send push notification: {e}")),
119 }
120 }
121
122 fn handle_error(&self, msg: String) -> Result<(), A2AError> {
123 if self.fail_on_error {
124 Err(A2AError::internal(&msg))
125 } else {
126 tracing::error!("{}", msg);
127 Ok(())
128 }
129 }
130}
131
132fn validate_push_url(url: &str) -> Result<(), A2AError> {
138 let parsed = reqwest::Url::parse(url)
139 .map_err(|_| A2AError::invalid_params("invalid push notification URL"))?;
140
141 if parsed.scheme() != "http" && parsed.scheme() != "https" {
142 return Err(A2AError::invalid_params("push URL must be http or https"));
143 }
144
145 if let Some(host) = parsed.host_str() {
146 let host = host.strip_suffix('.').unwrap_or(host);
153 let blocked = [
155 "127.0.0.1",
156 "localhost",
157 "::1",
158 "[::1]",
159 "169.254.169.254",
160 "metadata.google.internal",
161 "metadata.azure.com",
162 "metadata.goog",
163 "0.0.0.0",
164 ];
165 if blocked.contains(&host) {
166 return Err(A2AError::invalid_params("push URL targets blocked host"));
167 }
168 let host_unbracketed = host
174 .strip_prefix('[')
175 .and_then(|h| h.strip_suffix(']'))
176 .unwrap_or(host);
177 if let Ok(ip) = host_unbracketed.parse::<std::net::IpAddr>() {
178 let blocked = match ip {
179 std::net::IpAddr::V4(v4) => {
180 v4.is_loopback()
181 || v4.is_private()
182 || v4.is_link_local()
183 || v4.is_unspecified()
184 || v4.is_multicast()
185 }
186 std::net::IpAddr::V6(v6) => {
187 let is_ula = (v6.segments()[0] & 0xfe00) == 0xfc00;
189 let mapped_v4_blocked = v6.to_ipv4_mapped().is_some_and(|v4| {
193 v4.is_loopback()
194 || v4.is_private()
195 || v4.is_link_local()
196 || v4.is_unspecified()
197 || v4.is_multicast()
198 });
199 v6.is_loopback()
200 || is_ula
201 || mapped_v4_blocked
202 || v6.is_unicast_link_local()
203 || v6.is_unspecified()
204 || v6.is_multicast()
205 }
206 };
207 if blocked {
208 return Err(A2AError::invalid_params(
209 "push URL targets private/loopback/link-local address",
210 ));
211 }
212 }
213 }
214
215 Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 use crate::test_util::install_crypto_provider;
223
224 #[test]
225 fn test_default_config() {
226 let config = HttpPushSenderConfig::default();
227 assert_eq!(config.timeout, Duration::from_secs(30));
228 assert!(!config.fail_on_error);
229 assert!(config.validate_urls);
230 }
231
232 #[test]
233 fn test_sender_new_default() {
234 install_crypto_provider();
235 let sender = HttpPushSender::new(None);
236 assert!(!sender.fail_on_error);
237 assert!(sender.validate_urls);
238 }
239
240 #[test]
241 fn test_sender_new_custom() {
242 install_crypto_provider();
243 let config = HttpPushSenderConfig {
244 timeout: Duration::from_secs(10),
245 fail_on_error: true,
246 validate_urls: false,
247 };
248 let sender = HttpPushSender::new(Some(config));
249 assert!(sender.fail_on_error);
250 assert!(!sender.validate_urls);
251 }
252
253 #[test]
254 fn test_handle_error_fail_on_error() {
255 install_crypto_provider();
256 let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
257 timeout: Duration::from_secs(5),
258 fail_on_error: true,
259 validate_urls: true,
260 }));
261 let result = sender.handle_error("test error".to_string());
262 assert!(result.is_err());
263 }
264
265 #[test]
266 fn test_handle_error_ignore() {
267 install_crypto_provider();
268 let sender = HttpPushSender::new(None);
269 let result = sender.handle_error("test error".to_string());
270 assert!(result.is_ok());
271 }
272
273 fn sample_status_update() -> StreamResponse {
274 use a2a::event::*;
275 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
276 task_id: "t1".into(),
277 context_id: "c1".into(),
278 status: TaskStatus {
279 state: TaskState::Working,
280 message: None,
281 timestamp: None,
282 },
283 metadata: None,
284 })
285 }
286
287 fn sample_config(url: &str) -> TaskPushNotificationConfig {
288 TaskPushNotificationConfig {
289 task_id: String::new(),
290 url: url.to_string(),
291 id: None,
292 token: None,
293 authentication: None,
294 tenant: None,
295 }
296 }
297
298 #[tokio::test]
299 async fn test_send_push_connection_refused_no_fail() {
300 install_crypto_provider();
301 let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
302 validate_urls: false,
303 ..Default::default()
304 }));
305 let config = TaskPushNotificationConfig {
306 task_id: String::new(),
307 url: "http://127.0.0.1:1/callback".to_string(),
308 id: None,
309 token: Some("tok".to_string()),
310 authentication: Some(AuthenticationInfo {
311 scheme: "bearer".to_string(),
312 credentials: Some("secret".to_string()),
313 }),
314 tenant: None,
315 };
316 let result = sender.send_push(&config, sample_status_update()).await;
317 assert!(result.is_ok());
319 }
320
321 #[tokio::test]
322 async fn test_send_push_connection_refused_fail() {
323 install_crypto_provider();
324 let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
325 timeout: std::time::Duration::from_millis(100),
326 fail_on_error: true,
327 validate_urls: false,
328 }));
329 let config = sample_config("http://127.0.0.1:1/callback");
330 let result = sender.send_push(&config, sample_status_update()).await;
331 assert!(result.is_err());
332 }
333
334 #[tokio::test]
335 async fn test_send_push_with_basic_auth() {
336 install_crypto_provider();
337 let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
338 validate_urls: false,
339 ..Default::default()
340 }));
341 let config = TaskPushNotificationConfig {
342 task_id: String::new(),
343 url: "http://127.0.0.1:1/callback".to_string(),
344 id: None,
345 token: None,
346 authentication: Some(AuthenticationInfo {
347 scheme: "basic".to_string(),
348 credentials: Some("dXNlcjpwYXNz".to_string()),
349 }),
350 tenant: None,
351 };
352 let result = sender.send_push(&config, sample_status_update()).await;
353 assert!(result.is_ok()); }
356
357 #[tokio::test]
358 async fn test_send_push_with_unknown_auth_scheme() {
359 install_crypto_provider();
360 let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
361 validate_urls: false,
362 ..Default::default()
363 }));
364 let config = TaskPushNotificationConfig {
365 task_id: String::new(),
366 url: "http://127.0.0.1:1/callback".to_string(),
367 id: None,
368 token: None,
369 authentication: Some(AuthenticationInfo {
370 scheme: "custom".to_string(),
371 credentials: Some("cred".to_string()),
372 }),
373 tenant: None,
374 };
375 let result = sender.send_push(&config, sample_status_update()).await;
376 assert!(result.is_ok());
377 }
378
379 #[test]
380 fn test_validate_push_url_rejects_non_http_schemes() {
381 for url in [
382 "file:///etc/passwd",
383 "ftp://example.com/hook",
384 "gopher://example.com/",
385 ] {
386 let err = validate_push_url(url).unwrap_err();
387 assert_eq!(err.code, error_code::INVALID_PARAMS);
388 }
389 }
390
391 #[test]
392 fn test_validate_push_url_rejects_loopback() {
393 for url in [
394 "http://127.0.0.1:1/callback",
395 "http://localhost/callback",
396 "http://[::1]/callback",
397 "http://0.0.0.0/callback",
398 ] {
399 assert!(
400 validate_push_url(url).is_err(),
401 "expected {url} to be rejected"
402 );
403 }
404 }
405
406 #[test]
407 fn test_validate_push_url_rejects_private_and_link_local() {
408 for url in [
409 "http://10.0.0.1/callback",
410 "http://192.168.1.1/callback",
411 "http://172.16.0.1/callback",
412 "http://169.254.169.254/latest/meta-data/",
413 "http://169.254.1.1/callback",
414 "http://metadata.google.internal/callback",
415 ] {
416 assert!(
417 validate_push_url(url).is_err(),
418 "expected {url} to be rejected"
419 );
420 }
421 }
422
423 #[test]
424 fn test_validate_push_url_rejects_trailing_dot_hosts() {
425 for url in [
428 "http://localhost./hook",
429 "http://LocalHost./hook",
430 "https://localhost./hook",
431 "http://metadata.google.internal./hook",
432 "http://metadata.azure.com./hook",
433 ] {
434 assert!(
435 validate_push_url(url).is_err(),
436 "{url} must be blocked, but was allowed"
437 );
438 }
439 }
440
441 #[test]
442 fn test_validate_push_url_rejects_alternate_ipv4_spellings() {
443 for url in [
447 "http://2130706433/hook", "http://0x7f.0.0.1/hook", "http://127.1/hook", "http://127.0.0.1./hook", "http://[::ffff:127.0.0.1]/hook", ] {
453 assert!(
454 validate_push_url(url).is_err(),
455 "{url} must be blocked, but was allowed"
456 );
457 }
458 }
459
460 #[test]
461 fn test_validate_push_url_accepts_public_urls() {
462 for url in [
463 "http://example.com/callback",
464 "https://example.com/callback",
465 "https://hooks.example.com/abc?x=1",
466 ] {
467 assert!(
468 validate_push_url(url).is_ok(),
469 "expected {url} to be accepted"
470 );
471 }
472 }
473
474 #[tokio::test]
475 async fn test_send_push_rejects_blocked_url_even_without_fail_on_error() {
476 install_crypto_provider();
477 let sender = HttpPushSender::new(None); let config = sample_config("http://127.0.0.1:1/callback");
479 let result = sender.send_push(&config, sample_status_update()).await;
480 assert!(result.is_err());
482 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
483 }
484
485 #[tokio::test]
486 async fn test_send_push_rejects_crlf_credentials() {
487 install_crypto_provider();
488 let sender = HttpPushSender::new(None);
489 let config = TaskPushNotificationConfig {
490 task_id: String::new(),
491 url: "https://example.com/callback".to_string(),
492 id: None,
493 token: None,
494 authentication: Some(AuthenticationInfo {
495 scheme: "bearer".to_string(),
496 credentials: Some("secret\r\nX-Injected: 1".to_string()),
497 }),
498 tenant: None,
499 };
500 let result = sender.send_push(&config, sample_status_update()).await;
501 assert!(result.is_err());
502 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
503 }
504
505 #[tokio::test]
506 async fn test_send_push_rejects_crlf_token() {
507 install_crypto_provider();
508 let sender = HttpPushSender::new(None);
509 let config = TaskPushNotificationConfig {
510 task_id: String::new(),
511 url: "https://example.com/callback".to_string(),
512 id: None,
513 token: Some("tok\nX-Injected: 1".to_string()),
514 authentication: None,
515 tenant: None,
516 };
517 let result = sender.send_push(&config, sample_status_update()).await;
518 assert!(result.is_err());
519 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
520 }
521
522 #[test]
523 fn test_validate_push_url_rejects_invalid_url() {
524 assert!(validate_push_url("not a url").is_err());
525 assert!(validate_push_url("").is_err());
526 assert!(validate_push_url("http://").is_err());
528 }
529
530 #[test]
531 fn test_validate_push_url_rejects_ipv6_restricted_ranges() {
532 for url in [
533 "http://[fc00::1]/cb",
534 "http://[fd12:3456:789a::1]/cb",
535 "http://[fe80::1]/cb",
536 "http://[ff02::1]/cb",
537 "http://[::]/cb",
538 ] {
539 let result = validate_push_url(url);
540 assert!(result.is_err(), "expected {url} to be rejected");
541 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
542 }
543 }
544
545 #[test]
546 fn test_validate_push_url_rejects_ipv4_mapped_ipv6() {
547 for url in [
549 "http://[::ffff:127.0.0.1]/cb",
550 "http://[::ffff:10.0.0.1]/cb",
551 ] {
552 let result = validate_push_url(url);
553 assert!(result.is_err(), "expected {url} to be rejected");
554 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
555 }
556 }
557
558 #[test]
559 fn test_validate_push_url_rejects_ipv4_multicast() {
560 let result = validate_push_url("http://224.0.0.1/cb");
561 assert!(result.is_err());
562 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
563 }
564
565 #[test]
566 fn test_validate_push_url_accepts_global_ipv6() {
567 assert!(validate_push_url("http://[2606:4700::1111]/cb").is_ok());
568 }
569}