1mod types;
5
6#[cfg(feature = "async-http")]
7use std::error::Error as StdError;
8#[cfg(feature = "async-http")]
9use std::fmt;
10#[cfg(feature = "async-http")]
11use std::net::SocketAddr;
12#[cfg(any(feature = "async-http", test))]
13use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
14#[cfg(feature = "async-http")]
15use std::time::Duration;
16
17#[cfg(any(feature = "async-http", test))]
18use url::Url;
19
20pub use self::types::{
21 HttpMethod, ReqwestHttpTransport, RuntimeHttpHeader, RuntimeHttpRequest, RuntimeHttpResponse,
22 RuntimeHttpTransport,
23};
24
25#[cfg(feature = "async-http")]
26const MAX_HTTP_RESPONSE_BYTES: usize = 1024 * 1024;
27#[cfg(feature = "async-http")]
28const DEFAULT_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
29#[cfg(feature = "async-http")]
30const DEFAULT_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31#[cfg(feature = "async-http")]
32const MANAGED_AGENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(180);
33
34#[allow(dead_code)] pub const DEFAULT_BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
44
45#[cfg(feature = "async-http")]
52fn chrome_default_headers() -> reqwest::header::HeaderMap {
53 use reqwest::header::{HeaderMap, HeaderValue};
54 let mut headers = HeaderMap::new();
55 headers.insert(
56 "sec-ch-ua",
57 HeaderValue::from_static(
58 "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not/A)Brand\";v=\"24\"",
59 ),
60 );
61 headers.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
62 headers.insert(
63 "sec-ch-ua-platform",
64 HeaderValue::from_static("\"Windows\""),
65 );
66 headers.insert("upgrade-insecure-requests", HeaderValue::from_static("1"));
67 headers.insert(
68 "accept",
69 HeaderValue::from_static(
70 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
71 ),
72 );
73 headers.insert("sec-fetch-site", HeaderValue::from_static("none"));
74 headers.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
75 headers.insert("sec-fetch-user", HeaderValue::from_static("?1"));
76 headers.insert("sec-fetch-dest", HeaderValue::from_static("document"));
77 headers.insert(
78 "accept-language",
79 HeaderValue::from_static("en-US,en;q=0.9"),
80 );
81 headers.insert("priority", HeaderValue::from_static("u=0, i"));
82 headers
83}
84
85#[cfg(feature = "async-http")]
86impl ReqwestHttpTransport {
87 pub fn new() -> Result<Self, RuntimeHttpError> {
88 Self::with_timeouts_and_private_networks(
89 DEFAULT_HTTP_REQUEST_TIMEOUT,
90 DEFAULT_HTTP_CONNECT_TIMEOUT,
91 false,
92 None,
93 )
94 }
95
96 fn with_timeouts_and_private_networks(
97 request_timeout: Duration,
98 connect_timeout: Duration,
99 allow_private_networks: bool,
100 browser_user_agent: Option<String>,
101 ) -> Result<Self, RuntimeHttpError> {
102 let _ = rustls::crypto::ring::default_provider().install_default();
106 let mut builder = reqwest::Client::builder()
111 .redirect(reqwest::redirect::Policy::none())
112 .timeout(request_timeout)
113 .connect_timeout(connect_timeout)
114 .gzip(true)
115 .brotli(true)
116 .deflate(true)
117 .zstd(true);
118 if let Some(user_agent) = browser_user_agent {
123 builder = builder
124 .user_agent(user_agent)
125 .default_headers(chrome_default_headers());
126 }
127 if !allow_private_networks {
128 builder = builder.dns_resolver(GuardedDnsResolver::new(TokioDnsResolver));
129 }
130 let client = builder
131 .build()
132 .map_err(|error| RuntimeHttpError::Transport {
133 message: transport_error_message(&error),
134 })?;
135 Ok(Self {
136 client,
137 allow_private_networks,
138 })
139 }
140
141 pub fn with_private_network_access() -> Result<Self, RuntimeHttpError> {
146 Self::with_timeouts_and_private_networks(
147 DEFAULT_HTTP_REQUEST_TIMEOUT,
148 DEFAULT_HTTP_CONNECT_TIMEOUT,
149 true,
150 None,
151 )
152 }
153
154 pub fn for_managed_agent() -> Result<Self, RuntimeHttpError> {
159 Self::with_timeouts_and_private_networks(
160 MANAGED_AGENT_REQUEST_TIMEOUT,
161 DEFAULT_HTTP_CONNECT_TIMEOUT,
162 false,
163 None,
164 )
165 }
166
167 pub fn with_options(
173 allow_private_networks: bool,
174 browser_user_agent: Option<String>,
175 ) -> Result<Self, RuntimeHttpError> {
176 Self::with_timeouts_and_private_networks(
177 DEFAULT_HTTP_REQUEST_TIMEOUT,
178 DEFAULT_HTTP_CONNECT_TIMEOUT,
179 allow_private_networks,
180 browser_user_agent,
181 )
182 }
183
184 #[cfg(test)]
185 fn with_private_network_access_for_tests() -> Result<Self, RuntimeHttpError> {
186 Self::with_private_network_access()
187 }
188
189 #[cfg(test)]
190 fn with_private_network_timeouts_for_tests(
191 request_timeout: Duration,
192 connect_timeout: Duration,
193 ) -> Result<Self, RuntimeHttpError> {
194 Self::with_timeouts_and_private_networks(request_timeout, connect_timeout, true, None)
195 }
196}
197
198#[cfg(feature = "async-http")]
199impl RuntimeHttpTransport for ReqwestHttpTransport {
200 fn send(&self, request: RuntimeHttpRequest) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
201 validate_http_url(&request.url, self.allow_private_networks)?;
202 let client = self.client.clone();
203 block_on_http(async move {
204 let method = reqwest_method(request.method);
205 let mut builder = client.request(method, request.url);
206 for header in request.headers {
207 validate_header(&header)?;
208 let name = reqwest::header::HeaderName::from_bytes(header.name.trim().as_bytes())
209 .map_err(|error| RuntimeHttpError::InvalidHeaderName {
210 name: header.name.clone(),
211 message: error.to_string(),
212 })?;
213 let value =
214 reqwest::header::HeaderValue::from_str(&header.value).map_err(|error| {
215 RuntimeHttpError::InvalidHeaderValue {
216 name: header.name.clone(),
217 message: error.to_string(),
218 }
219 })?;
220 builder = builder.header(name, value);
221 }
222 if let Some(body) = request.body {
223 builder = builder.body(body);
224 }
225 let response = builder
226 .send()
227 .await
228 .map_err(|error| RuntimeHttpError::Transport {
229 message: transport_error_message(&error),
230 })?;
231 let status = response.status().as_u16();
232 let body = read_limited_response_body(response, MAX_HTTP_RESPONSE_BYTES).await?;
233 Ok(RuntimeHttpResponse { status, body })
234 })
235 }
236}
237
238#[cfg(feature = "async-http")]
239fn transport_error_message(error: &(dyn StdError + 'static)) -> String {
240 let mut parts = vec![error.to_string()];
241 let mut source = error.source();
242 while let Some(error) = source {
243 parts.push(error.to_string());
244 source = error.source();
245 }
246 parts.dedup();
247 parts.join(": ")
248}
249
250#[cfg(feature = "async-http")]
251#[derive(Clone, Debug)]
252struct GuardedDnsResolver<R> {
253 inner: R,
254}
255
256#[cfg(feature = "async-http")]
257impl<R> GuardedDnsResolver<R> {
258 fn new(inner: R) -> Self {
259 Self { inner }
260 }
261}
262
263#[cfg(feature = "async-http")]
264impl<R> reqwest::dns::Resolve for GuardedDnsResolver<R>
265where
266 R: reqwest::dns::Resolve + Clone + Send + Sync + 'static,
267{
268 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
269 let host = name.as_str().to_owned();
270 let inner = self.inner.clone();
271 Box::pin(async move {
272 let addrs = inner.resolve(name).await?;
273 let mut public_addrs = Vec::new();
274 for addr in addrs {
275 if is_private_network_ip(addr.ip()) {
276 return Err(PrivateDnsResolutionError { host, addr }.into());
277 }
278 public_addrs.push(addr);
279 }
280 if public_addrs.is_empty() {
281 return Err(EmptyDnsResolutionError { host }.into());
282 }
283 Ok(Box::new(public_addrs.into_iter()) as reqwest::dns::Addrs)
284 })
285 }
286}
287
288#[cfg(feature = "async-http")]
289#[derive(Clone, Copy, Debug, Default)]
290struct TokioDnsResolver;
291
292#[cfg(feature = "async-http")]
293impl reqwest::dns::Resolve for TokioDnsResolver {
294 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
295 let host = name.as_str().to_owned();
296 Box::pin(async move {
297 let addrs = tokio::net::lookup_host((host.as_str(), 0))
298 .await
299 .map_err(|error| Box::new(error) as Box<dyn StdError + Send + Sync>)?;
300 let addrs = addrs.collect::<Vec<_>>();
301 Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
302 })
303 }
304}
305
306#[cfg(feature = "async-http")]
307#[derive(Debug)]
308struct PrivateDnsResolutionError {
309 host: String,
310 addr: SocketAddr,
311}
312
313#[cfg(feature = "async-http")]
314impl fmt::Display for PrivateDnsResolutionError {
315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316 write!(
317 formatter,
318 "runtime HTTP DNS resolved '{}' to non-public address {}",
319 self.host, self.addr
320 )
321 }
322}
323
324#[cfg(feature = "async-http")]
325impl StdError for PrivateDnsResolutionError {}
326
327#[cfg(feature = "async-http")]
328#[derive(Debug)]
329struct EmptyDnsResolutionError {
330 host: String,
331}
332
333#[cfg(feature = "async-http")]
334impl fmt::Display for EmptyDnsResolutionError {
335 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336 write!(
337 formatter,
338 "runtime HTTP DNS returned no addresses for '{}'",
339 self.host
340 )
341 }
342}
343
344#[cfg(feature = "async-http")]
345impl StdError for EmptyDnsResolutionError {}
346
347#[derive(Clone, Debug)]
348#[cfg(any(feature = "async-http", test))]
349#[allow(dead_code)]
350pub struct RuntimeHttpClient<T = ReqwestHttpTransport> {
351 base_url: String,
352 transport: T,
353}
354
355#[cfg(any(feature = "async-http", test))]
356#[allow(dead_code)]
357impl<T: RuntimeHttpTransport> RuntimeHttpClient<T> {
358 pub fn with_transport(
359 base_url: impl AsRef<str>,
360 transport: T,
361 ) -> Result<Self, RuntimeHttpError> {
362 let base_url = strip_one_trailing_slash(base_url.as_ref());
363 validate_http_url(&base_url, false)?;
364 Ok(Self {
365 base_url,
366 transport,
367 })
368 }
369
370 pub fn route_url(&self, route: &str) -> Result<String, RuntimeHttpError> {
371 let normalized_route = route.trim_start_matches('/');
372 let url = format!("{}/{}", self.base_url, normalized_route);
373 validate_http_url(&url, false)?;
374 Ok(url)
375 }
376
377 pub fn request(
378 &self,
379 method: HttpMethod,
380 route: &str,
381 ) -> Result<RuntimeHttpRequest, RuntimeHttpError> {
382 Ok(RuntimeHttpRequest {
383 method,
384 url: self.route_url(route)?,
385 headers: Vec::new(),
386 body: None,
387 })
388 }
389
390 pub fn send(
391 &self,
392 request: RuntimeHttpRequest,
393 ) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
394 self.transport.send(request)
395 }
396}
397
398#[derive(Debug, thiserror::Error)]
399pub enum RuntimeHttpError {
400 #[error("invalid runtime HTTP url: {0}")]
401 InvalidUrl(#[from] url::ParseError),
402 #[error("runtime HTTP transport failed: {message}")]
403 Transport { message: String },
404 #[error("runtime HTTP transport cannot block inside an active async runtime")]
405 BlockingHttpInsideAsyncRuntime,
406 #[error("runtime HTTP async runtime is unavailable: {message}")]
407 AsyncRuntimeUnavailable { message: String },
408 #[error("runtime HTTP transport returned invalid output: {message}")]
409 TransportDecode { message: String },
410 #[error("runtime HTTP response body exceeds {limit} byte limit")]
411 ResponseBodyTooLarge { limit: usize },
412 #[error("unsupported runtime HTTP url scheme '{scheme}': only http and https are allowed")]
413 UnsupportedUrlScheme { scheme: String },
414 #[error("runtime HTTP url host '{host}' is not publicly routable")]
415 PrivateNetworkUrl { host: String },
416 #[error("invalid runtime HTTP header name '{name}': {message}")]
417 InvalidHeaderName { name: String, message: String },
418 #[error("invalid runtime HTTP header value for '{name}': {message}")]
419 InvalidHeaderValue { name: String, message: String },
420}
421
422pub(crate) fn strip_one_trailing_slash(value: &str) -> String {
423 value.strip_suffix('/').unwrap_or(value).to_owned()
424}
425
426#[cfg(feature = "async-http")]
427fn validate_header(header: &RuntimeHttpHeader) -> Result<(), RuntimeHttpError> {
428 let name = header.name.trim();
429 if name.is_empty() || !name.bytes().all(is_header_token_byte) {
430 return Err(RuntimeHttpError::InvalidHeaderName {
431 name: header.name.clone(),
432 message: "header names must be HTTP token characters".to_owned(),
433 });
434 }
435 if header.value.contains('\r') || header.value.contains('\n') {
436 return Err(RuntimeHttpError::InvalidHeaderValue {
437 name: header.name.clone(),
438 message: "header values must not contain line breaks".to_owned(),
439 });
440 }
441 Ok(())
442}
443
444#[cfg(any(feature = "async-http", test))]
445#[allow(dead_code)]
446fn validate_http_url(value: &str, allow_private_networks: bool) -> Result<(), RuntimeHttpError> {
447 let url = Url::parse(value)?;
448 match url.scheme() {
449 "http" | "https" => validate_public_host(&url, allow_private_networks),
450 scheme => Err(RuntimeHttpError::UnsupportedUrlScheme {
451 scheme: scheme.to_owned(),
452 }),
453 }
454}
455
456#[cfg(any(feature = "async-http", test))]
457fn validate_public_host(url: &Url, allow_private_networks: bool) -> Result<(), RuntimeHttpError> {
458 if allow_private_networks {
459 return Ok(());
460 }
461 let Some(host) = url.host_str() else {
462 return Err(RuntimeHttpError::PrivateNetworkUrl {
463 host: "<missing>".to_owned(),
464 });
465 };
466 let normalized = host.trim_end_matches('.').to_ascii_lowercase();
467 if normalized == "localhost"
468 || normalized.ends_with(".localhost")
469 || normalized == "metadata.google.internal"
470 {
471 return Err(RuntimeHttpError::PrivateNetworkUrl {
472 host: host.to_owned(),
473 });
474 }
475 let ip_host = normalized
476 .strip_prefix('[')
477 .and_then(|value| value.strip_suffix(']'))
478 .unwrap_or(&normalized);
479 if let Ok(ip) = ip_host.parse::<IpAddr>() {
480 if is_private_network_ip(ip) {
481 return Err(RuntimeHttpError::PrivateNetworkUrl {
482 host: host.to_owned(),
483 });
484 }
485 }
486 Ok(())
487}
488
489#[cfg(any(feature = "async-http", test))]
490fn is_private_network_ip(ip: IpAddr) -> bool {
491 match ip {
492 IpAddr::V4(ip) => is_private_network_ipv4(ip),
493 IpAddr::V6(ip) => is_private_network_ipv6(ip),
494 }
495}
496
497#[cfg(any(feature = "async-http", test))]
498fn is_private_network_ipv4(ip: Ipv4Addr) -> bool {
499 let octets = ip.octets();
500 ip.is_private()
501 || ip.is_loopback()
502 || ip.is_link_local()
503 || ip.is_broadcast()
504 || ip.is_documentation()
505 || ip.is_unspecified()
506 || ip.is_multicast()
507 || octets[0] == 0
508 || (octets[0] == 100 && (octets[1] & 0xc0) == 0x40)
509 || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
510 || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))
511 || octets[0] >= 240
512 || octets == [169, 254, 169, 254]
513}
514
515#[cfg(any(feature = "async-http", test))]
516fn is_private_network_ipv6(ip: Ipv6Addr) -> bool {
517 ip.to_ipv4_mapped().is_some_and(is_private_network_ipv4)
518 || ip.is_loopback()
519 || ip.is_unspecified()
520 || ip.is_multicast()
521 || is_unique_local_ipv6(ip)
522 || is_unicast_link_local_ipv6(ip)
523 || is_documentation_ipv6(ip)
524 || nat64_embedded_ipv4(ip).is_some_and(is_private_network_ipv4)
525 || six_to_four_embedded_ipv4(ip).is_some_and(is_private_network_ipv4)
526}
527
528#[cfg(any(feature = "async-http", test))]
529fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
530 (ip.segments()[0] & 0xfe00) == 0xfc00
531}
532
533#[cfg(any(feature = "async-http", test))]
534fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
535 (ip.segments()[0] & 0xffc0) == 0xfe80
536}
537
538#[cfg(any(feature = "async-http", test))]
539fn is_documentation_ipv6(ip: Ipv6Addr) -> bool {
540 ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8
541}
542
543#[cfg(any(feature = "async-http", test))]
544fn nat64_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
545 let segments = ip.segments();
546 if segments[..6] != [0x0064, 0xff9b, 0, 0, 0, 0] {
547 return None;
548 }
549 Some(Ipv4Addr::new(
550 (segments[6] >> 8) as u8,
551 segments[6] as u8,
552 (segments[7] >> 8) as u8,
553 segments[7] as u8,
554 ))
555}
556
557#[cfg(any(feature = "async-http", test))]
558fn six_to_four_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
559 let segments = ip.segments();
560 if segments[0] != 0x2002 {
561 return None;
562 }
563 Some(Ipv4Addr::new(
564 (segments[1] >> 8) as u8,
565 segments[1] as u8,
566 (segments[2] >> 8) as u8,
567 segments[2] as u8,
568 ))
569}
570
571#[cfg(feature = "async-http")]
572async fn read_limited_response_body(
573 mut response: reqwest::Response,
574 limit: usize,
575) -> Result<String, RuntimeHttpError> {
576 if declared_response_length(&response)?.is_some_and(|length| length > limit as u64) {
577 return Err(RuntimeHttpError::ResponseBodyTooLarge { limit });
578 }
579 let mut body = Vec::new();
580 while let Some(chunk) =
581 response
582 .chunk()
583 .await
584 .map_err(|error| RuntimeHttpError::TransportDecode {
585 message: error.to_string(),
586 })?
587 {
588 if body.len().saturating_add(chunk.len()) > limit {
589 return Err(RuntimeHttpError::ResponseBodyTooLarge { limit });
590 }
591 body.extend_from_slice(&chunk);
592 }
593 Ok(String::from_utf8_lossy(&body).into_owned())
594}
595
596#[cfg(feature = "async-http")]
597fn declared_response_length(response: &reqwest::Response) -> Result<Option<u64>, RuntimeHttpError> {
598 let Some(value) = response.headers().get(reqwest::header::CONTENT_LENGTH) else {
599 return Ok(response.content_length());
600 };
601 let value = value
602 .to_str()
603 .map_err(|error| RuntimeHttpError::TransportDecode {
604 message: format!("invalid Content-Length header: {error}"),
605 })?;
606 value
607 .parse::<u64>()
608 .map(Some)
609 .map_err(|error| RuntimeHttpError::TransportDecode {
610 message: format!("invalid Content-Length header: {error}"),
611 })
612}
613
614#[cfg(feature = "async-http")]
615fn reqwest_method(method: HttpMethod) -> reqwest::Method {
616 match method {
617 HttpMethod::Get => reqwest::Method::GET,
618 HttpMethod::Post => reqwest::Method::POST,
619 HttpMethod::Put => reqwest::Method::PUT,
620 HttpMethod::Patch => reqwest::Method::PATCH,
621 HttpMethod::Delete => reqwest::Method::DELETE,
622 }
623}
624
625#[cfg(feature = "async-http")]
626fn block_on_http<F, T>(future: F) -> Result<T, RuntimeHttpError>
627where
628 F: std::future::Future<Output = Result<T, RuntimeHttpError>>,
629{
630 if tokio::runtime::Handle::try_current().is_ok() {
631 return Err(RuntimeHttpError::BlockingHttpInsideAsyncRuntime);
632 }
633 let runtime = tokio::runtime::Builder::new_current_thread()
634 .enable_all()
635 .build()
636 .map_err(|error| RuntimeHttpError::AsyncRuntimeUnavailable {
637 message: error.to_string(),
638 })?;
639 runtime.block_on(future)
640}
641
642#[cfg(feature = "async-http")]
643fn is_header_token_byte(byte: u8) -> bool {
644 byte.is_ascii_alphanumeric()
645 || matches!(
646 byte,
647 b'!' | b'#'
648 | b'$'
649 | b'%'
650 | b'&'
651 | b'\''
652 | b'*'
653 | b'+'
654 | b'-'
655 | b'.'
656 | b'^'
657 | b'_'
658 | b'`'
659 | b'|'
660 | b'~'
661 )
662}
663
664#[cfg(test)]
665mod tests {
666 use std::cell::RefCell;
667 use std::io;
668 #[cfg(feature = "async-http")]
669 use std::io::{Read, Write};
670 #[cfg(feature = "async-http")]
671 use std::net::TcpListener;
672 #[cfg(feature = "async-http")]
673 use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
674 #[cfg(feature = "async-http")]
675 use std::time::Duration;
676
677 #[cfg(feature = "async-http")]
678 use super::{GuardedDnsResolver, MAX_HTTP_RESPONSE_BYTES, ReqwestHttpTransport, block_on_http};
679 use super::{
680 HttpMethod, RuntimeHttpClient, RuntimeHttpError, RuntimeHttpHeader, RuntimeHttpRequest,
681 RuntimeHttpResponse, RuntimeHttpTransport,
682 };
683 #[cfg(feature = "async-http")]
684 use reqwest::dns::Resolve as _;
685
686 #[derive(Default)]
687 struct MockTransport {
688 requests: RefCell<Vec<RuntimeHttpRequest>>,
689 }
690
691 impl RuntimeHttpTransport for &MockTransport {
692 fn send(
693 &self,
694 request: RuntimeHttpRequest,
695 ) -> Result<RuntimeHttpResponse, RuntimeHttpError> {
696 self.requests.borrow_mut().push(request);
697 Ok(RuntimeHttpResponse {
698 status: 204,
699 body: String::new(),
700 })
701 }
702 }
703
704 #[cfg(feature = "async-http")]
705 #[derive(Clone, Debug)]
706 struct StaticDnsResolver {
707 addrs: Vec<SocketAddr>,
708 }
709
710 #[cfg(feature = "async-http")]
711 impl reqwest::dns::Resolve for StaticDnsResolver {
712 fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
713 let addrs = self.addrs.clone();
714 Box::pin(async move { Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs) })
715 }
716 }
717
718 #[derive(Debug, thiserror::Error)]
719 enum RuntimeHttpTestError {
720 #[error(transparent)]
721 RuntimeHttp(#[from] RuntimeHttpError),
722 #[error(transparent)]
723 Io(#[from] io::Error),
724 #[cfg(feature = "async-http")]
725 #[error("server thread panicked")]
726 ServerThread,
727 }
728
729 #[test]
730 fn client_normalizes_base_url_and_routes_requests() -> Result<(), RuntimeHttpTestError> {
731 let transport = MockTransport::default();
732 let client = RuntimeHttpClient::with_transport("https://api.example/", &transport)?;
733
734 let mut request = client.request(HttpMethod::Delete, "/v1/grants/grant_1")?;
735 request
736 .headers
737 .push(RuntimeHttpHeader::new("accept", "application/json"));
738 request.body = Some("{\"ok\":true}".to_owned());
739 let response = client.send(request)?;
740
741 assert_eq!(response.status, 204);
742 let sent = transport.requests.borrow();
743 assert_eq!(sent[0].method, HttpMethod::Delete);
744 assert_eq!(sent[0].url, "https://api.example/v1/grants/grant_1");
745 assert_eq!(sent[0].headers[0].name, "accept");
746 assert_eq!(sent[0].body.as_deref(), Some("{\"ok\":true}"));
747 Ok(())
748 }
749
750 #[test]
751 fn debug_output_redacts_sensitive_header_values() {
752 let request = RuntimeHttpRequest {
753 method: HttpMethod::Get,
754 url: "https://api.example/v1/grants".to_owned(),
755 headers: vec![
756 RuntimeHttpHeader::new("authorization", "Bearer SECRET_RUNTIME_TOKEN"),
757 RuntimeHttpHeader::new("x-runx-token", "SECRET_HEADER_TOKEN"),
758 RuntimeHttpHeader::new("accept", "application/json"),
759 ],
760 body: Some("SECRET_BODY".to_owned()),
761 };
762
763 let debug = format!("{request:?}");
764 assert!(!debug.contains("SECRET_RUNTIME_TOKEN"));
765 assert!(!debug.contains("SECRET_HEADER_TOKEN"));
766 assert!(!debug.contains("SECRET_BODY"));
767 assert!(debug.contains("[redacted]"));
768 assert!(debug.contains("application/json"));
769 }
770
771 #[test]
772 fn invalid_base_urls_fail_closed() {
773 assert!(RuntimeHttpClient::with_transport("not a url", &MockTransport::default()).is_err());
774 assert!(matches!(
775 RuntimeHttpClient::with_transport("file:///tmp/runx.sock", &MockTransport::default()),
776 Err(RuntimeHttpError::UnsupportedUrlScheme { .. })
777 ));
778 }
779
780 #[test]
781 fn private_network_base_urls_fail_closed() {
782 for value in [
783 "http://localhost",
784 "http://service.localhost",
785 "http://127.0.0.1",
786 "http://10.0.0.1",
787 "http://172.16.0.1",
788 "http://192.168.0.1",
789 "http://169.254.169.254",
790 "http://100.64.0.1",
791 "http://100.127.255.255",
792 "http://192.0.0.1",
793 "http://198.18.0.1",
794 "http://240.0.0.1",
795 "http://0.1.2.3",
796 "http://[::1]",
797 "http://[::ffff:127.0.0.1]",
798 "http://[64:ff9b::7f00:1]",
799 "http://[2002:7f00:1::]",
800 "http://[fc00::1]",
801 "http://[fe80::1]",
802 "http://metadata.google.internal",
803 ] {
804 assert!(
805 matches!(
806 RuntimeHttpClient::with_transport(value, &MockTransport::default()),
807 Err(RuntimeHttpError::PrivateNetworkUrl { .. })
808 ),
809 "{value} should be rejected as private"
810 );
811 }
812 }
813
814 #[test]
815 fn public_base_urls_are_allowed() -> Result<(), RuntimeHttpTestError> {
816 RuntimeHttpClient::with_transport("https://api.example", &MockTransport::default())?;
817 RuntimeHttpClient::with_transport("http://8.8.8.8", &MockTransport::default())?;
818 RuntimeHttpClient::with_transport("http://[64:ff9b::808:808]", &MockTransport::default())?;
819 Ok(())
820 }
821
822 #[test]
823 #[cfg(feature = "async-http")]
824 fn guarded_dns_resolver_rejects_private_resolved_addresses() -> Result<(), RuntimeHttpTestError>
825 {
826 let resolver = GuardedDnsResolver::new(StaticDnsResolver {
827 addrs: vec![SocketAddr::V4(SocketAddrV4::new(
828 Ipv4Addr::new(127, 0, 0, 1),
829 0,
830 ))],
831 });
832 let name = "public.example"
833 .parse()
834 .map_err(|error| RuntimeHttpError::Transport {
835 message: format!("test DNS name should parse: {error}"),
836 })?;
837 let error =
838 block_on_http(async {
839 resolver.resolve(name).await.map(|_| ()).map_err(|error| {
840 RuntimeHttpError::Transport {
841 message: error.to_string(),
842 }
843 })
844 })
845 .err();
846
847 assert!(
848 matches!(error, Some(RuntimeHttpError::Transport { ref message }) if message.contains("non-public address")),
849 "expected private DNS resolution to fail closed, got: {error:?}"
850 );
851 Ok(())
852 }
853
854 #[test]
855 #[cfg(feature = "async-http")]
856 fn reqwest_transport_does_not_follow_redirects() -> Result<(), RuntimeHttpTestError> {
857 let listener = TcpListener::bind("127.0.0.1:0")?;
858 let address = listener.local_addr()?;
859 let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
860 let (mut stream, _) = listener.accept()?;
861 let mut buffer = [0_u8; 1024];
862 let bytes_read = stream.read(&mut buffer)?;
863 stream.write_all(
864 b"HTTP/1.1 302 Found\r\nLocation: /redirected\r\nContent-Length: 0\r\n\r\n",
865 )?;
866 Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
867 });
868
869 let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
870 let response = transport.send(RuntimeHttpRequest {
871 method: HttpMethod::Get,
872 url: format!("http://{address}/start"),
873 headers: Vec::new(),
874 body: None,
875 })?;
876 let request = server
877 .join()
878 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
879
880 assert_eq!(response.status, 302);
881 assert!(request.starts_with("GET /start "));
882 Ok(())
883 }
884
885 #[test]
886 #[cfg(feature = "async-http")]
887 fn reqwest_transport_rejects_header_injection() -> Result<(), RuntimeHttpTestError> {
888 let transport = ReqwestHttpTransport::new()?;
889 let error = transport
890 .send(RuntimeHttpRequest {
891 method: HttpMethod::Get,
892 url: "https://api.example/v1".to_owned(),
893 headers: vec![RuntimeHttpHeader::new("x-runx", "good\nbad")],
894 body: None,
895 })
896 .err();
897 assert!(matches!(
898 error,
899 Some(RuntimeHttpError::InvalidHeaderValue { .. })
900 ));
901 Ok(())
902 }
903
904 #[cfg(feature = "async-http")]
905 #[test]
906 fn reqwest_transport_rejects_non_http_urls_before_sending() -> Result<(), RuntimeHttpTestError>
907 {
908 let transport = ReqwestHttpTransport::new()?;
909 let error = transport
910 .send(RuntimeHttpRequest {
911 method: HttpMethod::Get,
912 url: "file:///etc/passwd".to_owned(),
913 headers: Vec::new(),
914 body: None,
915 })
916 .err();
917
918 assert!(matches!(
919 error,
920 Some(RuntimeHttpError::UnsupportedUrlScheme { .. })
921 ));
922 Ok(())
923 }
924
925 #[cfg(feature = "async-http")]
926 #[test]
927 fn reqwest_transport_rejects_oversized_content_length() -> Result<(), RuntimeHttpTestError> {
928 let listener = TcpListener::bind("127.0.0.1:0")?;
929 let address = listener.local_addr()?;
930 let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
931 let (mut stream, _) = listener.accept()?;
932 let mut buffer = [0_u8; 1024];
933 let _ = stream.read(&mut buffer)?;
934 let response = format!(
935 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
936 MAX_HTTP_RESPONSE_BYTES + 1
937 );
938 stream.write_all(response.as_bytes())?;
939 Ok(())
940 });
941
942 let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
943 let error = transport
944 .send(RuntimeHttpRequest {
945 method: HttpMethod::Get,
946 url: format!("http://{address}/too-large"),
947 headers: Vec::new(),
948 body: None,
949 })
950 .err();
951 server
952 .join()
953 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
954
955 assert!(matches!(
956 error,
957 Some(RuntimeHttpError::ResponseBodyTooLarge { limit })
958 if limit == MAX_HTTP_RESPONSE_BYTES
959 ));
960 Ok(())
961 }
962
963 #[cfg(feature = "async-http")]
964 #[test]
965 fn reqwest_transport_caps_streamed_response_body() -> Result<(), RuntimeHttpTestError> {
966 let listener = TcpListener::bind("127.0.0.1:0")?;
967 let address = listener.local_addr()?;
968 let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
969 let (mut stream, _) = listener.accept()?;
970 let mut buffer = [0_u8; 1024];
971 let _ = stream.read(&mut buffer)?;
972 stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n")?;
973 let _ = stream.write_all(&vec![b'a'; MAX_HTTP_RESPONSE_BYTES + 1]);
974 Ok(())
975 });
976
977 let transport = ReqwestHttpTransport::with_private_network_access_for_tests()?;
978 let error = transport
979 .send(RuntimeHttpRequest {
980 method: HttpMethod::Get,
981 url: format!("http://{address}/stream-too-large"),
982 headers: Vec::new(),
983 body: None,
984 })
985 .err();
986 server
987 .join()
988 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
989
990 assert!(matches!(
991 error,
992 Some(RuntimeHttpError::ResponseBodyTooLarge { limit })
993 if limit == MAX_HTTP_RESPONSE_BYTES
994 ));
995 Ok(())
996 }
997
998 #[cfg(feature = "async-http")]
999 #[test]
1000 fn reqwest_transport_times_out_stalled_response() -> Result<(), RuntimeHttpTestError> {
1001 let listener = TcpListener::bind("127.0.0.1:0")?;
1002 let address = listener.local_addr()?;
1003 let server = std::thread::spawn(move || -> Result<(), std::io::Error> {
1004 let (_stream, _) = listener.accept()?;
1005 std::thread::sleep(Duration::from_millis(500));
1006 Ok(())
1007 });
1008
1009 let transport = ReqwestHttpTransport::with_private_network_timeouts_for_tests(
1010 Duration::from_millis(100),
1011 Duration::from_millis(100),
1012 )?;
1013 let error = transport
1014 .send(RuntimeHttpRequest {
1015 method: HttpMethod::Get,
1016 url: format!("http://{address}/stall"),
1017 headers: Vec::new(),
1018 body: None,
1019 })
1020 .err();
1021 server
1022 .join()
1023 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1024
1025 assert!(matches!(error, Some(RuntimeHttpError::Transport { .. })));
1026 Ok(())
1027 }
1028
1029 #[cfg(feature = "async-http")]
1030 #[test]
1031 fn browser_profile_sends_chrome_ua_and_client_hints() -> Result<(), RuntimeHttpTestError> {
1032 let listener = TcpListener::bind("127.0.0.1:0")?;
1033 let address = listener.local_addr()?;
1034 let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
1035 let (mut stream, _) = listener.accept()?;
1036 let mut buffer = [0_u8; 4096];
1037 let bytes_read = stream.read(&mut buffer)?;
1038 stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")?;
1039 Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
1040 });
1041
1042 let transport = ReqwestHttpTransport::with_options(
1044 true,
1045 Some(super::DEFAULT_BROWSER_USER_AGENT.to_owned()),
1046 )?;
1047 transport.send(RuntimeHttpRequest {
1048 method: HttpMethod::Get,
1049 url: format!("http://{address}/probe"),
1050 headers: Vec::new(),
1051 body: None,
1052 })?;
1053 let request = server
1054 .join()
1055 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1056
1057 let lower = request.to_ascii_lowercase();
1058 assert!(
1059 lower.contains("chrome/143"),
1060 "browser UA should be sent: {request}"
1061 );
1062 assert!(
1063 lower.contains("sec-ch-ua"),
1064 "client-hint headers should be sent: {request}"
1065 );
1066 assert!(
1067 lower.contains("sec-fetch-mode"),
1068 "fetch-metadata headers should be sent: {request}"
1069 );
1070 Ok(())
1071 }
1072
1073 #[cfg(feature = "async-http")]
1074 #[test]
1075 fn caller_header_overrides_browser_default() -> Result<(), RuntimeHttpTestError> {
1076 let listener = TcpListener::bind("127.0.0.1:0")?;
1077 let address = listener.local_addr()?;
1078 let server = std::thread::spawn(move || -> Result<String, std::io::Error> {
1079 let (mut stream, _) = listener.accept()?;
1080 let mut buffer = [0_u8; 4096];
1081 let bytes_read = stream.read(&mut buffer)?;
1082 stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")?;
1083 Ok(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
1084 });
1085
1086 let transport = ReqwestHttpTransport::with_options(
1087 true,
1088 Some(super::DEFAULT_BROWSER_USER_AGENT.to_owned()),
1089 )?;
1090 transport.send(RuntimeHttpRequest {
1091 method: HttpMethod::Get,
1092 url: format!("http://{address}/probe"),
1093 headers: vec![RuntimeHttpHeader::new("accept", "application/json")],
1094 body: None,
1095 })?;
1096 let request = server
1097 .join()
1098 .map_err(|_| RuntimeHttpTestError::ServerThread)??;
1099
1100 let lower = request.to_ascii_lowercase();
1101 assert!(
1102 lower.contains("accept: application/json"),
1103 "caller Accept should be present: {request}"
1104 );
1105 assert!(
1106 !lower.contains("text/html"),
1107 "browser default Accept should be overridden, not duplicated: {request}"
1108 );
1109 Ok(())
1110 }
1111}