1use std::error::Error as _;
31use std::fmt::Write as _;
32use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
33use std::sync::Arc;
34
35use async_trait::async_trait;
36use bytes::Bytes;
37use cratefield_core::{
38 ByteStream, HttpClient, HttpError, HttpPolicy, MAX_CONCURRENT_REQUESTS,
39 declared_content_length, scrub_request_url,
40};
41use http::header::{
42 AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, LOCATION, PROXY_AUTHORIZATION,
43};
44use reqwest::Url;
45use tokio::sync::Semaphore;
46
47const MAX_REDIRECTS: usize = 10;
50
51const GUARD_TAG: &str = "blocked-by-outbound-policy";
54
55const BLOCKED_HOST_SUFFIXES: [&str; 3] = [".internal", ".internal.", ".local"];
58
59const BLOCKED_HOSTS: [&str; 3] = ["metadata", "metadata.google", "metadata.google.internal"];
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct OutboundOptions {
68 pub allow_loopback: bool,
74 pub max_concurrent: usize,
78}
79
80impl Default for OutboundOptions {
81 fn default() -> Self {
82 Self {
83 allow_loopback: false,
84 max_concurrent: MAX_CONCURRENT_REQUESTS,
85 }
86 }
87}
88
89pub struct ReqwestClient {
92 client: reqwest::Client,
93 budget: Arc<Semaphore>,
94 allow_loopback: bool,
95}
96
97impl Default for ReqwestClient {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl ReqwestClient {
104 #[must_use]
113 pub fn new() -> Self {
114 Self::with_options(OutboundOptions::default())
115 }
116
117 #[must_use]
125 pub fn with_options(options: OutboundOptions) -> Self {
126 Self {
127 client: reqwest::Client::builder()
128 .redirect(reqwest::redirect::Policy::none())
129 .dns_resolver(Arc::new(GuardResolver {
134 allow_loopback: options.allow_loopback,
135 }))
136 .build()
137 .expect("reqwest initializes: rustls with webpki roots is static"),
138 budget: Arc::new(Semaphore::new(options.max_concurrent.max(1))),
139 allow_loopback: options.allow_loopback,
140 }
141 }
142}
143
144#[async_trait]
145impl HttpClient for ReqwestClient {
146 async fn send(
147 &self,
148 request: http::Request<Bytes>,
149 ) -> Result<http::Response<Bytes>, HttpError> {
150 let policy = HttpPolicy::of_request(&request);
151 let permit = Arc::clone(&self.budget).try_acquire_owned().map_err(|_| {
152 HttpError::Transport("outbound concurrency budget exhausted".to_owned())
153 })?;
154 let start = tokio::time::Instant::now();
155
156 let (parts, mut body) = request.into_parts();
157 let mut method = parts.method;
158 let mut headers = parts.headers;
159 let mut url = Url::parse(&parts.uri.to_string())
160 .map_err(|err| HttpError::Transport(format!("invalid request uri: {err}")))?;
161 vet_static(&url, self.allow_loopback)?;
162 vet_resolved_host(&url, self.allow_loopback).await?;
163
164 let response = self
165 .exchange_until_final(
166 Some(ExchangeDeadline { policy, start }),
167 &mut method,
168 &mut headers,
169 &mut body,
170 &mut url,
171 )
172 .await?;
173
174 if let Some(declared) = declared_content_length(response.headers())
175 && declared > policy.max_response_bytes
176 {
177 return Err(HttpError::ResponseTooLarge {
178 limit: policy.max_response_bytes,
179 });
180 }
181 let status = response.status();
182 let headers_out = response.headers().clone();
183 let bytes = read_capped(response, policy.max_response_bytes).await?;
184 drop(permit);
187 let mut builder = http::Response::builder().status(status);
188 if let Some(target) = builder.headers_mut() {
189 *target = headers_out;
190 }
191 builder
192 .body(bytes)
193 .map_err(|err| HttpError::Transport(err.to_string()))
194 }
195
196 async fn send_streaming(
203 &self,
204 request: http::Request<Bytes>,
205 ) -> Result<http::Response<ByteStream>, HttpError> {
206 let permit = Arc::clone(&self.budget).try_acquire_owned().map_err(|_| {
210 HttpError::Transport("outbound concurrency budget exhausted".to_owned())
211 })?;
212 let (parts, mut body) = request.into_parts();
213 let mut method = parts.method;
214 let mut headers = parts.headers;
215 let mut url = Url::parse(&parts.uri.to_string())
216 .map_err(|err| HttpError::Transport(format!("invalid request uri: {err}")))?;
217 vet_static(&url, self.allow_loopback)?;
218 vet_resolved_host(&url, self.allow_loopback).await?;
219
220 let response = self
227 .exchange_until_final(None, &mut method, &mut headers, &mut body, &mut url)
228 .await?;
229
230 let status = response.status();
231 let headers_out = response.headers().clone();
232 let mut builder = http::Response::builder().status(status);
233 if let Some(target) = builder.headers_mut() {
234 *target = headers_out;
235 }
236 let body: ByteStream = Box::pin(futures_util::stream::unfold(
237 StreamingBody {
238 response: Some(response),
239 permit,
240 },
241 |mut state| async move {
242 let mut response = state.response.take()?;
246 match response.chunk().await {
247 Ok(Some(chunk)) => {
248 state.response = Some(response);
249 Some((Ok(chunk), state))
250 }
251 Ok(None) => None,
256 Err(err) => Some((Err(HttpError::Transport(safe_message(&err))), state)),
257 }
258 },
259 ));
260 builder
261 .body(body)
262 .map_err(|err| HttpError::Transport(err.to_string()))
263 }
264}
265
266struct ExchangeDeadline {
279 policy: HttpPolicy,
280 start: tokio::time::Instant,
281}
282
283impl ReqwestClient {
284 async fn exchange_until_final(
287 &self,
288 deadline: Option<ExchangeDeadline>,
289 method: &mut http::Method,
290 headers: &mut http::HeaderMap,
291 body: &mut Bytes,
292 url: &mut Url,
293 ) -> Result<reqwest::Response, HttpError> {
294 for hop in 0.. {
295 let mut builder = self.client.request(method.clone(), url.clone());
296 for (name, value) in headers.iter() {
297 builder = builder.header(name.clone(), value.clone());
298 }
299 if !body.is_empty() {
300 builder = builder.body(body.clone());
301 }
302 if let Some(deadline) = &deadline {
303 let remaining = deadline
304 .policy
305 .timeout
306 .saturating_sub(deadline.start.elapsed());
307 if remaining.is_zero() {
308 return Err(HttpError::DeadlineExceeded {
309 after: deadline.policy.timeout,
310 });
311 }
312 builder = builder.timeout(remaining);
313 }
314 let response = match builder.send().await {
315 Ok(response) => response,
316 Err(err) if err.is_timeout() => {
317 match &deadline {
320 Some(deadline) => {
321 return Err(HttpError::DeadlineExceeded {
322 after: deadline.policy.timeout,
323 });
324 }
325 None => return Err(send_failure(&err)),
326 }
327 }
328 Err(err) => {
329 return Err(send_failure(&err));
333 }
334 };
335 let status = response.status();
336 if !is_redirect(status) {
337 return Ok(response);
338 }
339 let Some(next) = response
340 .headers()
341 .get(LOCATION)
342 .and_then(|value| value.to_str().ok())
343 .and_then(|value| url.join(value).ok())
344 else {
345 return Ok(response);
347 };
348 if hop == MAX_REDIRECTS {
349 return Err(HttpError::Transport(format!(
350 "redirect limit ({MAX_REDIRECTS}) exceeded"
351 )));
352 }
353 vet_static(&next, self.allow_loopback)?;
354 vet_resolved_host(&next, self.allow_loopback).await?;
355 if next.authority() != url.authority() {
356 headers.remove(AUTHORIZATION);
359 headers.remove(COOKIE);
360 headers.remove(PROXY_AUTHORIZATION);
361 }
362 if redirect_changes_method(status, method) {
363 *method = http::Method::GET;
364 *body = Bytes::new();
365 headers.remove(CONTENT_TYPE);
366 headers.remove(CONTENT_LENGTH);
367 }
368 *url = next;
369 }
370 unreachable!("bounded by the MAX_REDIRECTS return")
371 }
372}
373
374fn is_redirect(status: http::StatusCode) -> bool {
375 matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308)
376}
377
378fn redirect_changes_method(status: http::StatusCode, method: &http::Method) -> bool {
379 match status.as_u16() {
380 303 => *method != http::Method::GET && *method != http::Method::HEAD,
381 301 | 302 => *method == http::Method::POST,
382 _ => false,
383 }
384}
385
386async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result<Bytes, HttpError> {
390 let mut buffer: Vec<u8> = Vec::new();
391 while let Some(chunk) = response
392 .chunk()
393 .await
394 .map_err(|err| HttpError::Transport(safe_message(&err)))?
395 {
396 if buffer.len() + chunk.len() > limit {
397 return Err(HttpError::ResponseTooLarge { limit });
398 }
399 buffer.extend_from_slice(&chunk);
400 }
401 Ok(Bytes::from(buffer))
402}
403
404struct StreamingBody {
410 response: Option<reqwest::Response>,
414 #[allow(dead_code)] permit: tokio::sync::OwnedSemaphorePermit,
418}
419
420fn safe_message(err: &reqwest::Error) -> String {
437 let mut message = err.to_string();
438 let mut cause = err.source();
439 while let Some(current) = cause {
440 let _ = write!(message, ": {current}");
441 cause = current.source();
442 }
443 scrub_url(message, err.url())
444}
445
446fn send_failure(err: &reqwest::Error) -> HttpError {
449 let message = safe_message(err);
450 if message.contains(GUARD_TAG) {
454 HttpError::BlockedDestination(message)
455 } else {
456 HttpError::Transport(message)
457 }
458}
459
460fn scrub_url(message: String, url: Option<&Url>) -> String {
470 match url {
471 Some(url) => scrub_request_url(&message, url.as_str()),
472 None => message,
473 }
474}
475
476fn vet_static(url: &Url, allow_loopback: bool) -> Result<(), HttpError> {
481 if url.scheme() != "http" && url.scheme() != "https" {
482 return Err(HttpError::BlockedDestination(format!(
483 "scheme `{}` is not allowed",
484 url.scheme()
485 )));
486 }
487 if !url.username().is_empty() || url.password().is_some() {
488 return Err(HttpError::BlockedDestination(
489 "userinfo in a request uri is not allowed".to_owned(),
490 ));
491 }
492 let Some(raw) = url.host_str() else {
493 return Err(HttpError::BlockedDestination("url has no host".to_owned()));
494 };
495 if let Some(v6) = raw
496 .strip_prefix('[')
497 .and_then(|inner| inner.strip_suffix(']'))
498 .and_then(|inner| inner.parse::<Ipv6Addr>().ok())
499 {
500 return vet_ip_with(IpAddr::V6(v6), allow_loopback);
501 }
502 if let Ok(v4) = raw.parse::<Ipv4Addr>() {
503 return vet_ip_with(IpAddr::V4(v4), allow_loopback);
504 }
505 vet_domain(raw)
506}
507
508fn vet_domain(name: &str) -> Result<(), HttpError> {
512 let lower = name.to_ascii_lowercase();
513 let trimmed = lower.strip_suffix('.').unwrap_or(&lower);
514 let refused = |what: &str| Err(HttpError::BlockedDestination(format!("`{name}` {what}")));
515 if trimmed == "localhost" || trimmed.ends_with(".localhost") {
516 return refused("is a loopback name");
519 }
520 if BLOCKED_HOSTS.contains(&trimmed)
521 || BLOCKED_HOST_SUFFIXES
522 .iter()
523 .any(|suffix| trimmed.ends_with(suffix))
524 {
525 return refused("names a private or metadata service");
526 }
527 Ok(())
528}
529
530async fn vet_resolved_host(url: &Url, allow_loopback: bool) -> Result<(), HttpError> {
535 let Some(raw) = url.host_str() else {
536 return Err(HttpError::BlockedDestination("url has no host".to_owned()));
537 };
538 if raw.parse::<Ipv4Addr>().is_ok()
539 || raw
540 .strip_prefix('[')
541 .and_then(|inner| inner.strip_suffix(']'))
542 .is_some_and(|inner| inner.parse::<Ipv6Addr>().is_ok())
543 {
544 return Ok(());
545 }
546 let addrs = resolve(raw).await.map_err(|err| {
547 HttpError::BlockedDestination(format!(
548 "`{raw}` cannot be resolved ({err}); destinations are vetted fail-closed"
549 ))
550 })?;
551 if addrs.is_empty() {
552 return Err(HttpError::BlockedDestination(format!(
553 "`{raw}` resolves to no address"
554 )));
555 }
556 for ip in addrs {
557 vet_ip_with(ip, allow_loopback)?;
558 }
559 Ok(())
560}
561
562async fn resolve(name: &str) -> Result<Vec<IpAddr>, std::io::Error> {
563 let name = name.to_owned();
564 tokio::task::spawn_blocking(move || {
565 use std::net::ToSocketAddrs;
566 (name.as_str(), 0u16)
567 .to_socket_addrs()
568 .map(|addrs| addrs.map(|addr| addr.ip()).collect::<Vec<_>>())
569 })
570 .await
571 .map_err(std::io::Error::other)?
572}
573
574#[must_use]
583pub fn vet_ip_ok(ip: IpAddr, allow_loopback: bool) -> bool {
584 vet_ip_with(ip, allow_loopback).is_ok()
585}
586
587fn vet_ip_with(ip: IpAddr, allow_loopback: bool) -> Result<(), HttpError> {
588 let blocked = Err(HttpError::BlockedDestination(format!(
589 "{ip} is not a publicly-routable destination"
590 )));
591 match ip {
592 IpAddr::V4(v4) => {
593 if v4.is_loopback() {
594 return if allow_loopback { Ok(()) } else { blocked };
595 }
596 let octets = v4.octets();
597 let cgnat = octets[0] == 100 && octets[1] & 0b1100_0000 == 0b0100_0000;
598 let benchmarking = octets[0] == 198 && octets[1] & 0b1111_1110 == 18;
601 let reserved = octets[0] >= 240;
602 if v4.is_private()
603 || v4.is_link_local()
604 || v4.is_unspecified()
605 || v4.is_multicast()
606 || v4.is_broadcast()
607 || v4.is_documentation()
608 || benchmarking
609 || reserved
610 || octets[0] == 0
611 || cgnat
613 {
614 blocked
615 } else {
616 Ok(())
617 }
618 }
619 IpAddr::V6(v6) => {
620 if v6.is_loopback() {
621 return if allow_loopback { Ok(()) } else { blocked };
622 }
623 let link_local = v6.segments()[0] & 0xffc0 == 0xfe80;
626 let unique_local = v6.segments()[0] & 0xfe00 == 0xfc00;
627 let documentation = v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8;
628 if v6.is_unspecified()
629 || v6.is_multicast()
630 || link_local
631 || unique_local
632 || documentation
633 {
634 return blocked;
635 }
636 if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0 {
640 return blocked;
641 }
642 if let Some(mapped) = v6.to_ipv4_mapped() {
643 return vet_ip_with(IpAddr::V4(mapped), allow_loopback);
644 }
645 let octets = v6.octets();
646 let embedded_v4 = || Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
647 if v6.segments()[..6] == [0, 0, 0, 0, 0, 0] {
653 return vet_ip_with(IpAddr::V4(embedded_v4()), allow_loopback);
654 }
655 if v6.segments()[0] == 0x0064 && v6.segments()[1] == 0xff9b {
659 return vet_ip_with(IpAddr::V4(embedded_v4()), allow_loopback);
660 }
661 if v6.segments()[0] == 0x2002 {
663 let octets = v6.octets();
664 let embedded = Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5]);
665 return vet_ip_with(IpAddr::V4(embedded), allow_loopback);
666 }
667 Ok(())
668 }
669 }
670}
671
672struct GuardResolver {
676 allow_loopback: bool,
677}
678
679impl reqwest::dns::Resolve for GuardResolver {
680 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
681 let allow_loopback = self.allow_loopback;
682 let name = name.as_str().to_owned();
683 Box::pin(async move {
684 let refusal = |why: String| -> Box<dyn std::error::Error + Send + Sync> {
685 Box::new(std::io::Error::other(format!("{GUARD_TAG}: {why}")))
686 };
687 let addrs = resolve(&name)
688 .await
689 .map_err(|err| refusal(format!("`{name}` cannot be resolved ({err})")))?;
690 if addrs.is_empty() {
691 return Err(refusal(format!("`{name}` resolves to no address")));
692 }
693 for ip in &addrs {
694 if let Err(err) = vet_ip_with(*ip, allow_loopback) {
695 return Err(refusal(err.to_string()));
696 }
697 }
698 Ok(Box::new(
699 addrs
700 .into_iter()
701 .map(|ip| std::net::SocketAddr::new(ip, 0))
702 .collect::<Vec<_>>()
703 .into_iter(),
704 ) as reqwest::dns::Addrs)
705 })
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 fn url(s: &str) -> Url {
714 Url::parse(s).expect("test url")
715 }
716
717 fn refused(s: &str) -> bool {
718 match vet_static(&url(s), false) {
719 Ok(()) => false,
720 Err(HttpError::BlockedDestination(_)) => true,
721 Err(other) => panic!("expected a policy refusal for {s}, got {other}"),
722 }
723 }
724
725 #[test]
726 fn loopback_is_blocked_everywhere_it_can_be_written() {
727 for s in [
730 "http://127.0.0.1/",
731 "http://127.1/",
732 "http://2130706433/",
733 "http://0x7f000001/",
734 "http://0177.0.0.1/",
735 "http://[::1]/",
736 "http://[::ffff:7f00:1]/",
737 "http://[0:0:0:0:0:ffff:127.0.0.1]/",
738 ] {
739 assert!(refused(s), "{s} must be refused");
740 }
741 }
742
743 #[test]
744 fn private_and_special_ranges_are_blocked() {
745 for s in [
746 "http://10.0.0.1/",
747 "http://192.168.70.70/",
748 "http://172.16.0.1/",
749 "http://0.0.0.0/",
750 "http://[fe80::1]/",
751 "http://[::]/",
752 "http://[ff02::1]/",
753 "http://[2001:db8::1]/",
754 "http://100.100.100.200/",
755 "http://255.255.255.255/",
756 ] {
757 assert!(refused(s), "{s} must be refused");
758 }
759 }
760
761 #[test]
762 fn cloud_metadata_endpoints_are_blocked() {
763 for s in [
765 "http://169.254.169.254/latest/meta-data/",
766 "http://[fd00:ec2::254]/",
767 "http://metadata.google.internal/",
768 "http://metadata/",
769 "http://foo.internal/",
770 "http://printer.local/",
771 ] {
772 assert!(refused(s), "{s} must be refused");
773 }
774 }
775
776 #[test]
777 fn non_http_schemes_and_userinfo_are_blocked() {
778 for s in [
779 "file:///etc/passwd",
780 "gopher://127.0.0.1:6379/",
781 "ftp://example.com/",
782 "http://admin:pw@example.com/",
783 "https://tok@169.254.169.254/",
784 ] {
785 assert!(refused(s), "{s} must be refused");
786 }
787 }
788
789 #[test]
790 fn ipv6_tunnel_forms_cannot_smuggle_an_ipv4_destination() {
791 assert!(refused("http://[2002:7f00:1::1]/"));
793 assert!(refused("http://[2001:0:4136:8000:0:63bf:3fff:fdd2]/"));
794 assert!(refused("http://[::127.0.0.1]/"));
797 assert!(refused("http://[::7f00:1]/"));
798 assert!(refused("http://[::10.0.0.1]/"));
799 assert!(refused("http://[64:ff9b::169.254.169.254]/"));
801 assert!(!refused("http://[::93.184.216.34]/"));
804 assert!(!refused("http://[64:ff9b::93.184.216.34]/"));
805 }
806
807 #[test]
808 fn legitimate_public_destinations_still_pass() {
809 for s in [
810 "https://api.resend.com/emails",
811 "https://93.184.216.34/",
812 "http://172.15.255.255/",
814 "http://172.32.0.1/",
815 "http://100.63.255.255/",
816 "http://100.128.0.1/",
817 ] {
818 assert!(!refused(s), "{s} must be allowed");
819 }
820 }
821
822 #[test]
823 fn loopback_opt_in_admits_only_loopback() {
824 assert!(!vet_ip_ok(IpAddr::V4(Ipv4Addr::LOCALHOST), false));
825 assert!(vet_ip_ok(IpAddr::V4(Ipv4Addr::LOCALHOST), true));
826 assert!(
827 !vet_ip_ok(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), true),
828 "metadata must stay blocked with loopback on"
829 );
830 assert!(
831 !vet_ip_ok(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)), true),
832 "private must stay blocked with loopback on"
833 );
834 }
835
836 #[test]
837 fn names_are_refused_before_any_lookup() {
838 assert!(refused("http://localhost:8080/x"));
840 assert!(refused("http://LOCALHOST./x"));
841 assert!(refused("http://sub.localhost/x"));
842 assert!(!refused("http://api.example.com/x"));
843 }
844
845 #[tokio::test]
856 async fn a_transport_failure_names_the_origin_and_never_the_request_path() {
857 const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
858
859 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
862 let addr = listener.local_addr().expect("its address");
863 drop(listener);
864
865 let client = ReqwestClient::with_options(OutboundOptions {
869 allow_loopback: true,
870 ..OutboundOptions::default()
871 });
872 let request = http::Request::builder()
873 .method(http::Method::POST)
874 .uri(format!("http://{addr}/3/device/{TOKEN}"))
875 .body(Bytes::new())
876 .expect("test request");
877
878 let err = client
879 .send(request)
880 .await
881 .expect_err("nothing is listening");
882 let HttpError::Transport(message) = &err else {
883 panic!("a refused connect is a transport failure, got {err}");
884 };
885 assert!(
886 !message.contains(TOKEN),
887 "the device token reached the error message: {message}"
888 );
889 assert!(
890 !message.contains("/3/device"),
891 "the request path reached the error message: {message}"
892 );
893 assert!(
896 message.contains(&addr.to_string()),
897 "the destination is named: {message}"
898 );
899 assert!(
900 message.to_ascii_lowercase().contains("refused")
901 || message.to_ascii_lowercase().contains("connect"),
902 "the cause survives the redaction: {message}"
903 );
904 }
905
906 #[test]
907 fn method_change_follows_the_http_rules() {
908 let post = http::Method::POST;
909 let get = http::Method::GET;
910 let code = |n: u16| http::StatusCode::from_u16(n).expect("test status");
911 assert!(redirect_changes_method(code(303), &post));
912 assert!(redirect_changes_method(code(302), &post));
913 assert!(!redirect_changes_method(code(307), &post));
914 assert!(!redirect_changes_method(code(301), &get));
915 assert!(!redirect_changes_method(code(303), &get));
916 }
917}