1use std::collections::HashMap;
18use std::future::{Future, IntoFuture};
19use std::net::IpAddr;
20use std::pin::Pin;
21use std::time::{Duration, Instant};
22
23use reqwest::{Client, Method};
24use serde::de::DeserializeOwned;
25use serde_json::Value;
26use std::sync::LazyLock;
27use tokio::time;
28use tracing::{debug, warn};
29use url::Url;
30
31use crate::retry::RetryPolicy;
32use crate::trace_context::WorkflowTraceContext;
33
34const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
36
37use crate::error::OperationError;
38#[cfg(feature = "prometheus")]
39use crate::metric_names;
40use crate::utils::MAX_OUTPUT_SIZE;
41
42fn is_blocked_ip(ip: IpAddr) -> bool {
46 match ip {
47 IpAddr::V4(v4) => {
48 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() || v4.is_unspecified() }
54 IpAddr::V6(v6) => {
55 v6.is_loopback() || v6.is_unspecified() }
58 }
59}
60
61fn check_url_host(raw: &str) -> Option<String> {
65 let parsed = Url::parse(raw).ok()?;
66 let host_str = parsed.host_str()?;
67
68 let host_clean = host_str.trim_start_matches('[').trim_end_matches(']');
69
70 if let Ok(ip) = host_clean.parse::<IpAddr>()
71 && is_blocked_ip(ip)
72 {
73 return Some(format!(
74 "URL targets a blocked IP address ({ip}): private, loopback, and link-local addresses are not allowed"
75 ));
76 }
77 None
78}
79
80static HTTP_CLIENT: LazyLock<Client> = LazyLock::new(|| {
81 Client::builder()
82 .redirect(reqwest::redirect::Policy::none())
83 .build()
84 .expect("failed to build HTTP client")
85});
86
87#[must_use = "an Http request does nothing until .run() or .await is called"]
116pub struct Http {
117 method: Method,
118 url: String,
119 headers: HashMap<String, String>,
120 body: Option<HttpBody>,
121 timeout: Option<Duration>,
122 max_response_size: usize,
123 dry_run: Option<bool>,
124 retry_policy: Option<RetryPolicy>,
125}
126
127enum HttpBody {
128 Text(String),
129 Json(Value),
130}
131
132impl Http {
133 pub fn new(method: Method, url: &str) -> Self {
139 let trimmed = url.trim();
140 assert!(!trimmed.is_empty(), "url must not be empty");
141 assert!(
142 trimmed.starts_with("http://") || trimmed.starts_with("https://"),
143 "url must use http:// or https:// scheme, got: {trimmed}"
144 );
145 Self {
146 method,
147 url: trimmed.to_string(),
148 headers: HashMap::new(),
149 body: None,
150 timeout: Some(DEFAULT_HTTP_TIMEOUT),
151 max_response_size: MAX_OUTPUT_SIZE,
152 dry_run: None,
153 retry_policy: None,
154 }
155 }
156
157 pub fn get(url: &str) -> Self {
170 Self::new(Method::GET, url)
171 }
172
173 pub fn post(url: &str) -> Self {
175 Self::new(Method::POST, url)
176 }
177
178 pub fn put(url: &str) -> Self {
180 Self::new(Method::PUT, url)
181 }
182
183 pub fn patch(url: &str) -> Self {
185 Self::new(Method::PATCH, url)
186 }
187
188 pub fn delete(url: &str) -> Self {
190 Self::new(Method::DELETE, url)
191 }
192
193 pub fn header(mut self, key: &str, value: &str) -> Self {
197 self.headers.insert(key.to_string(), value.to_string());
198 self
199 }
200
201 pub fn json(mut self, value: Value) -> Self {
206 self.body = Some(HttpBody::Json(value));
207 self
208 }
209
210 pub fn text(mut self, body: &str) -> Self {
212 self.body = Some(HttpBody::Text(body.to_string()));
213 self
214 }
215
216 pub fn timeout(mut self, timeout: Duration) -> Self {
221 self.timeout = Some(timeout);
222 self
223 }
224
225 pub fn max_response_size(mut self, bytes: usize) -> Self {
230 self.max_response_size = bytes;
231 self
232 }
233
234 pub fn retry(mut self, max_retries: u32) -> Self {
260 self.retry_policy = Some(RetryPolicy::new(max_retries));
261 self
262 }
263
264 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
289 self.retry_policy = Some(policy);
290 self
291 }
292
293 pub fn trace_context(self, ctx: &WorkflowTraceContext) -> Self {
315 self.header("traceparent", &ctx.to_traceparent())
316 }
317
318 pub fn dry_run(mut self, enabled: bool) -> Self {
327 self.dry_run = Some(enabled);
328 self
329 }
330
331 #[tracing::instrument(name = "http", skip_all, fields(method = %self.method, url = %self.url))]
344 pub async fn run(self) -> Result<HttpOutput, OperationError> {
345 if crate::dry_run::effective_dry_run(self.dry_run) {
346 debug!(method = %self.method, url = %self.url, "[dry-run] http request skipped");
347 return Ok(HttpOutput {
348 status: 200,
349 headers: HashMap::new(),
350 body: String::new(),
351 duration_ms: 0,
352 });
353 }
354
355 if let Some(reason) = check_url_host(&self.url) {
356 return Err(OperationError::Http {
357 status: None,
358 message: reason,
359 });
360 }
361
362 let result = self.execute_once().await;
363
364 let policy = match &self.retry_policy {
365 Some(p) => p,
366 None => return result,
367 };
368
369 match &result {
372 Ok(output) if !crate::retry::is_retryable_status(output.status) => return result,
373 Err(err) if !crate::retry::is_retryable(err) => return result,
374 _ => {}
375 }
376
377 let mut last_result = result;
378
379 for attempt in 0..policy.max_retries {
380 let delay = policy.delay_for_attempt(attempt);
381 warn!(
382 attempt = attempt + 1,
383 max_retries = policy.max_retries,
384 delay_ms = delay.as_millis() as u64,
385 "retrying http request"
386 );
387 time::sleep(delay).await;
388
389 last_result = self.execute_once().await;
390
391 match &last_result {
392 Ok(output) if !crate::retry::is_retryable_status(output.status) => {
393 return last_result;
394 }
395 Err(err) if !crate::retry::is_retryable(err) => return last_result,
396 _ => {}
397 }
398 }
399
400 last_result
401 }
402
403 async fn execute_once(&self) -> Result<HttpOutput, OperationError> {
405 debug!(method = %self.method, url = %self.url, "executing http request");
406 let start = Instant::now();
407
408 #[cfg(feature = "prometheus")]
409 let method_label = self.method.to_string();
410
411 let mut builder = HTTP_CLIENT.request(self.method.clone(), &self.url);
412
413 if let Some(timeout) = self.timeout {
414 builder = builder.timeout(timeout);
415 }
416
417 for (k, v) in &self.headers {
418 builder = builder.header(k.as_str(), v.as_str());
419 }
420
421 match &self.body {
422 Some(HttpBody::Json(v)) => {
423 builder = builder.json(v);
424 }
425 Some(HttpBody::Text(t)) => {
426 builder = builder.body(t.clone());
427 }
428 None => {}
429 }
430
431 let response = match builder.send().await {
432 Ok(resp) => resp,
433 Err(e) => {
434 #[cfg(feature = "prometheus")]
435 {
436 metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => metric_names::STATUS_ERROR).increment(1);
437 }
438 return Err(OperationError::Http {
439 status: None,
440 message: format!("request failed: {e}"),
441 });
442 }
443 };
444
445 let status = response.status().as_u16();
446 let headers: HashMap<String, String> = response
447 .headers()
448 .iter()
449 .map(|(k, v)| {
450 let val = match v.to_str() {
451 Ok(s) => s.to_string(),
452 Err(_) => {
453 debug!(header = %k, "non-UTF-8 header value, replacing with empty string");
454 String::new()
455 }
456 };
457 (k.to_string(), val)
458 })
459 .collect();
460 let max_response_size = self.max_response_size;
461 let response_too_large = |size: usize, limit: usize| OperationError::Http {
462 status: Some(status),
463 message: format!(
464 "response body too large: {size} bytes exceeds limit of {limit} bytes"
465 ),
466 };
467
468 if let Some(cl) = response.content_length() {
469 let content_length = usize::try_from(cl).unwrap_or(usize::MAX);
470 if content_length > max_response_size {
471 return Err(response_too_large(content_length, max_response_size));
472 }
473 }
474
475 let mut body_bytes = Vec::new();
476 let mut response = response;
477 loop {
478 match response.chunk().await {
479 Ok(Some(chunk)) => {
480 if body_bytes.len() + chunk.len() > max_response_size {
481 return Err(response_too_large(
482 body_bytes.len() + chunk.len(),
483 max_response_size,
484 ));
485 }
486 body_bytes.extend_from_slice(&chunk);
487 }
488 Ok(None) => break,
489 Err(e) => {
490 return Err(OperationError::Http {
491 status: Some(status),
492 message: format!("failed to read response body: {e}"),
493 });
494 }
495 }
496 }
497
498 let body = String::from_utf8_lossy(&body_bytes).into_owned();
499 let duration_ms = start.elapsed().as_millis() as u64;
500
501 debug!(
502 status,
503 body_len = body.len(),
504 duration_ms,
505 "http request completed"
506 );
507
508 #[cfg(feature = "prometheus")]
509 {
510 let status_label = status.to_string();
511 metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => status_label).increment(1);
512 metrics::histogram!(metric_names::HTTP_DURATION_SECONDS)
513 .record(duration_ms as f64 / 1000.0);
514 }
515
516 Ok(HttpOutput {
517 status,
518 headers,
519 body,
520 duration_ms,
521 })
522 }
523}
524
525impl IntoFuture for Http {
526 type Output = Result<HttpOutput, OperationError>;
527 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
528
529 fn into_future(self) -> Self::IntoFuture {
530 Box::pin(self.run())
531 }
532}
533
534#[derive(Debug)]
538pub struct HttpOutput {
539 status: u16,
540 headers: HashMap<String, String>,
541 body: String,
542 duration_ms: u64,
543}
544
545impl HttpOutput {
546 pub fn status(&self) -> u16 {
548 self.status
549 }
550
551 pub fn headers(&self) -> &HashMap<String, String> {
553 &self.headers
554 }
555
556 pub fn body(&self) -> &str {
558 &self.body
559 }
560
561 pub fn json<T: DeserializeOwned>(&self) -> Result<T, OperationError> {
567 serde_json::from_str(&self.body).map_err(OperationError::deserialize::<T>)
568 }
569
570 pub fn duration_ms(&self) -> u64 {
572 self.duration_ms
573 }
574
575 pub fn is_success(&self) -> bool {
577 (200..300).contains(&self.status)
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn get_builder_sets_method_and_url() {
587 let http = Http::get("https://example.com");
588 assert_eq!(http.method, Method::GET);
589 assert_eq!(http.url, "https://example.com");
590 }
591
592 #[test]
593 fn post_builder_sets_method() {
594 let http = Http::post("https://example.com");
595 assert_eq!(http.method, Method::POST);
596 }
597
598 #[test]
599 fn put_builder_sets_method() {
600 assert_eq!(Http::put("https://x.com").method, Method::PUT);
601 }
602
603 #[test]
604 fn patch_builder_sets_method() {
605 assert_eq!(Http::patch("https://x.com").method, Method::PATCH);
606 }
607
608 #[test]
609 fn delete_builder_sets_method() {
610 assert_eq!(Http::delete("https://x.com").method, Method::DELETE);
611 }
612
613 #[test]
614 fn header_builder_stores_headers() {
615 let http = Http::get("https://x.com")
616 .header("Authorization", "Bearer token")
617 .header("Accept", "application/json");
618 assert_eq!(http.headers.get("Authorization").unwrap(), "Bearer token");
619 assert_eq!(http.headers.get("Accept").unwrap(), "application/json");
620 }
621
622 #[test]
623 fn timeout_builder_stores_duration() {
624 let http = Http::get("https://x.com").timeout(Duration::from_secs(60));
625 assert_eq!(http.timeout, Some(Duration::from_secs(60)));
626 }
627
628 #[test]
629 fn default_timeout_is_30_seconds() {
630 let http = Http::get("https://x.com");
631 assert_eq!(http.timeout, Some(DEFAULT_HTTP_TIMEOUT));
632 }
633
634 #[test]
635 fn http_output_is_success_for_2xx() {
636 for status in [200, 201, 202, 204, 299] {
637 let output = HttpOutput {
638 status,
639 headers: HashMap::new(),
640 body: String::new(),
641 duration_ms: 0,
642 };
643 assert!(output.is_success(), "expected {status} to be success");
644 }
645 }
646
647 #[test]
648 fn http_output_is_not_success_for_non_2xx() {
649 for status in [100, 301, 400, 401, 403, 404, 500, 503] {
650 let output = HttpOutput {
651 status,
652 headers: HashMap::new(),
653 body: String::new(),
654 duration_ms: 0,
655 };
656 assert!(!output.is_success(), "expected {status} to not be success");
657 }
658 }
659
660 #[test]
661 fn http_output_json_parses_valid_json() {
662 let output = HttpOutput {
663 status: 200,
664 headers: HashMap::new(),
665 body: r#"{"name":"test","count":42}"#.to_string(),
666 duration_ms: 0,
667 };
668 let parsed: serde_json::Value = output.json().unwrap();
669 assert_eq!(parsed["name"], "test");
670 assert_eq!(parsed["count"], 42);
671 }
672
673 #[test]
674 fn http_output_json_fails_on_invalid_json() {
675 let output = HttpOutput {
676 status: 200,
677 headers: HashMap::new(),
678 body: "not json".to_string(),
679 duration_ms: 0,
680 };
681 let err = output.json::<serde_json::Value>().unwrap_err();
682 assert!(matches!(err, OperationError::Deserialize { .. }));
683 }
684
685 #[test]
686 #[should_panic(expected = "url must not be empty")]
687 fn empty_url_panics() {
688 let _ = Http::get("");
689 }
690
691 #[test]
692 #[should_panic(expected = "url must not be empty")]
693 fn whitespace_url_panics() {
694 let _ = Http::post(" ");
695 }
696
697 #[test]
698 #[should_panic(expected = "url must use http:// or https://")]
699 fn non_http_scheme_panics() {
700 let _ = Http::get("file:///etc/passwd");
701 }
702
703 #[test]
704 #[should_panic(expected = "url must use http:// or https://")]
705 fn ftp_scheme_panics() {
706 let _ = Http::get("ftp://example.com");
707 }
708
709 #[tokio::test]
710 async fn ssrf_localhost_blocked() {
711 let err = Http::get("http://127.0.0.1/secret")
712 .run()
713 .await
714 .unwrap_err();
715 assert!(err.to_string().contains("blocked IP address"));
716 }
717
718 #[tokio::test]
719 async fn ssrf_metadata_blocked() {
720 let err = Http::get("http://169.254.169.254/latest/meta-data/")
721 .run()
722 .await
723 .unwrap_err();
724 assert!(err.to_string().contains("blocked IP address"));
725 }
726
727 #[tokio::test]
728 async fn ssrf_private_10_blocked() {
729 let err = Http::get("http://10.0.0.1/internal")
730 .run()
731 .await
732 .unwrap_err();
733 assert!(err.to_string().contains("blocked IP address"));
734 }
735
736 #[tokio::test]
737 async fn ssrf_ipv6_loopback_blocked() {
738 let err = Http::get("http://[::1]/secret").run().await.unwrap_err();
739 assert!(err.to_string().contains("blocked IP address"));
740 }
741
742 #[test]
743 fn ssrf_public_ip_allowed() {
744 let _ = Http::get("http://8.8.8.8/dns");
746 }
747
748 #[test]
749 fn ssrf_hostname_allowed() {
750 let _ = Http::get("https://example.com/api");
752 }
753
754 #[tokio::test]
755 async fn ssrf_172_16_blocked() {
756 let err = Http::get("http://172.16.0.1/internal")
757 .run()
758 .await
759 .unwrap_err();
760 assert!(err.to_string().contains("blocked IP address"));
761 }
762
763 #[tokio::test]
764 async fn ssrf_192_168_blocked() {
765 let err = Http::get("http://192.168.1.1/admin")
766 .run()
767 .await
768 .unwrap_err();
769 assert!(err.to_string().contains("blocked IP address"));
770 }
771
772 #[tokio::test]
773 async fn ssrf_unspecified_blocked() {
774 let err = Http::get("http://0.0.0.0/").run().await.unwrap_err();
775 assert!(err.to_string().contains("blocked IP address"));
776 }
777
778 #[tokio::test]
779 async fn ssrf_broadcast_blocked() {
780 let err = Http::get("http://255.255.255.255/")
781 .run()
782 .await
783 .unwrap_err();
784 assert!(err.to_string().contains("blocked IP address"));
785 }
786
787 #[tokio::test]
788 async fn ssrf_localhost_with_port_blocked() {
789 let err = Http::get("http://127.0.0.1:8080/secret")
790 .run()
791 .await
792 .unwrap_err();
793 assert!(err.to_string().contains("blocked IP address"));
794 }
795
796 #[test]
797 fn url_trimming_stores_trimmed() {
798 let http = Http::get(" https://example.com ");
799 assert_eq!(http.url, "https://example.com");
800 }
801
802 #[test]
803 fn text_body_builder() {
804 let http = Http::post("https://x.com").text("hello body");
805 assert!(matches!(http.body, Some(HttpBody::Text(ref s)) if s == "hello body"));
806 }
807
808 #[test]
809 fn json_body_builder_stores_value() {
810 let http = Http::post("https://x.com").json(serde_json::json!({"k": "v"}));
811 assert!(matches!(http.body, Some(HttpBody::Json(_))));
812 }
813
814 #[test]
815 fn max_response_size_builder() {
816 let http = Http::get("https://x.com").max_response_size(1024);
817 assert_eq!(http.max_response_size, 1024);
818 }
819
820 #[test]
821 fn dry_run_builder_stores_flag() {
822 let http = Http::get("https://x.com").dry_run(true);
823 assert_eq!(http.dry_run, Some(true));
824 }
825
826 #[test]
827 fn retry_builder_stores_policy() {
828 let http = Http::get("https://x.com").retry(3);
829 assert!(http.retry_policy.is_some());
830 assert_eq!(http.retry_policy.unwrap().max_retries(), 3);
831 }
832
833 #[test]
834 fn retry_policy_builder_stores_custom_policy() {
835 let policy = RetryPolicy::new(5)
836 .backoff(Duration::from_secs(1))
837 .multiplier(3.0);
838 let http = Http::get("https://x.com").retry_policy(policy);
839 let p = http.retry_policy.unwrap();
840 assert_eq!(p.max_retries(), 5);
841 assert_eq!(p.initial_backoff, Duration::from_secs(1));
842 }
843
844 #[test]
845 fn no_retry_by_default() {
846 let http = Http::get("https://x.com");
847 assert!(http.retry_policy.is_none());
848 }
849
850 #[test]
851 fn http_output_accessors() {
852 let mut headers = HashMap::new();
853 headers.insert("content-type".to_string(), "text/plain".to_string());
854 let output = HttpOutput {
855 status: 201,
856 headers,
857 body: "hello".to_string(),
858 duration_ms: 42,
859 };
860 assert_eq!(output.status(), 201);
861 assert_eq!(output.body(), "hello");
862 assert_eq!(output.duration_ms(), 42);
863 assert_eq!(output.headers().get("content-type").unwrap(), "text/plain");
864 }
865
866 #[tokio::test]
867 async fn ssrf_userinfo_in_url_blocked() {
868 let err = Http::get("http://user:pass@127.0.0.1/secret")
869 .run()
870 .await
871 .unwrap_err();
872 assert!(err.to_string().contains("blocked IP address"));
873 }
874
875 #[test]
876 fn check_url_host_with_userinfo_detects_blocked_ip() {
877 let result = check_url_host("http://admin:secret@10.0.0.1/path");
878 assert!(result.is_some());
879 assert!(result.unwrap().contains("blocked IP address"));
880 }
881
882 #[test]
883 fn check_url_host_public_ip_with_userinfo_allowed() {
884 let result = check_url_host("http://user:pass@8.8.8.8/dns");
885 assert!(result.is_none());
886 }
887
888 #[test]
889 fn redirect_policy_is_none() {
890 let client = &*HTTP_CLIENT;
891 let _ = client;
892 }
893
894 #[tokio::test]
895 async fn no_redirect_returns_3xx_status() {
896 use tokio::net::TcpListener;
897
898 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
899 let port = listener.local_addr().unwrap().port();
900
901 let server = tokio::spawn(async move {
902 let (mut socket, _) = listener.accept().await.unwrap();
903 use tokio::io::AsyncWriteExt;
904 let response =
905 "HTTP/1.1 302 Found\r\nLocation: http://10.0.0.1/evil\r\nContent-Length: 0\r\n\r\n";
906 socket.write_all(response.as_bytes()).await.unwrap();
907 socket.shutdown().await.unwrap();
908 });
909
910 let url = format!("http://localhost:{port}/test");
911
912 let output = Http::get(&url)
913 .timeout(Duration::from_secs(5))
914 .run()
915 .await
916 .unwrap();
917
918 assert_eq!(output.status(), 302);
919
920 server.await.unwrap();
921 }
922
923 #[tokio::test]
924 async fn streaming_body_size_check_aborts_over_limit() {
925 use tokio::net::TcpListener;
926
927 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
928 let port = listener.local_addr().unwrap().port();
929
930 let server = tokio::spawn(async move {
931 let (mut socket, _) = listener.accept().await.unwrap();
932 use tokio::io::AsyncWriteExt;
933 let body = "x".repeat(2048);
934 let response = format!(
935 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{}\r\n0\r\n\r\n",
936 body.len(),
937 body,
938 );
939 socket.write_all(response.as_bytes()).await.unwrap();
940 socket.shutdown().await.unwrap();
941 });
942
943 let url = format!("http://localhost:{port}/big");
944
945 let result = Http::new(Method::GET, &url)
946 .max_response_size(1024)
947 .timeout(Duration::from_secs(5))
948 .run()
949 .await;
950
951 assert!(result.is_err());
952 let err = result.unwrap_err();
953 assert!(err.to_string().contains("response body too large"));
954
955 server.await.unwrap();
956 }
957}