1use crate::error::FaucetError;
53use crate::io::CallContext;
54use crate::resolve::ReferencePolicy;
55use reqwest::header::{self, HeaderMap, HeaderName};
56use reqwest::{Method, Request, RequestBuilder, Response, StatusCode, Url};
57use serde::de::DeserializeOwned;
58use std::fmt;
59use std::sync::Arc;
60use std::time::Duration;
61
62pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1 << 30;
64
65pub const DEFAULT_MAX_REDIRECTS: usize = 10;
67
68#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ResponseTooLarge {
73 pub url: String,
75 pub limit: u64,
77}
78
79impl fmt::Display for ResponseTooLarge {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(
82 f,
83 "response from {} is larger than the {}-byte limit",
84 self.url, self.limit
85 )
86 }
87}
88
89impl std::error::Error for ResponseTooLarge {}
90
91#[must_use]
93pub struct ClientBuilder {
94 inner: reqwest::ClientBuilder,
95 max_response_bytes: Option<u64>,
96 max_redirects: usize,
97 sensitive: Vec<HeaderName>,
98 policy: Option<Arc<ReferencePolicy>>,
99 error: Option<FaucetError>,
100}
101
102impl fmt::Debug for ClientBuilder {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 f.debug_struct("ClientBuilder")
105 .field("max_response_bytes", &self.max_response_bytes)
106 .field("max_redirects", &self.max_redirects)
107 .field("sensitive", &self.sensitive)
108 .finish_non_exhaustive()
109 }
110}
111
112impl Default for ClientBuilder {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118impl ClientBuilder {
119 #[allow(clippy::disallowed_methods)] pub fn new() -> Self {
123 Self {
124 inner: reqwest::Client::builder(),
125 max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES),
126 max_redirects: DEFAULT_MAX_REDIRECTS,
127 sensitive: Vec::new(),
128 policy: None,
129 error: None,
130 }
131 }
132
133 pub fn timeout(mut self, timeout: Duration) -> Self {
135 self.inner = self.inner.timeout(timeout);
136 self
137 }
138
139 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
141 self.inner = self.inner.connect_timeout(timeout);
142 self
143 }
144
145 pub fn read_timeout(mut self, timeout: Duration) -> Self {
148 self.inner = self.inner.read_timeout(timeout);
149 self
150 }
151
152 pub fn user_agent(mut self, user_agent: &str) -> Self {
154 self.inner = self.inner.user_agent(user_agent.to_string());
155 self
156 }
157
158 pub fn default_headers(mut self, headers: HeaderMap) -> Self {
160 self.inner = self.inner.default_headers(headers);
161 self
162 }
163
164 pub fn proxy(mut self, url: &str) -> Self {
167 match reqwest::Proxy::all(url) {
168 Ok(p) => self.inner = self.inner.proxy(p),
169 Err(e) => {
170 self.error = Some(FaucetError::Config(format!(
171 "invalid proxy URL {}: {e}",
172 crate::util::redact_uri_credentials(url)
173 )))
174 }
175 }
176 self
177 }
178
179 pub fn no_proxy(mut self) -> Self {
181 self.inner = self.inner.no_proxy();
182 self
183 }
184
185 pub fn max_redirects(mut self, n: usize) -> Self {
187 self.max_redirects = n;
188 self
189 }
190
191 pub fn max_response_bytes(mut self, limit: Option<u64>) -> Self {
193 self.max_response_bytes = limit;
194 self
195 }
196
197 pub fn reference_policy(mut self, policy: Arc<ReferencePolicy>) -> Self {
201 self.policy = Some(policy);
202 self
203 }
204
205 pub fn sensitive_header(mut self, name: &str) -> Self {
209 match HeaderName::from_bytes(name.as_bytes()) {
210 Ok(n) => self.sensitive.push(n),
211 Err(_) => {
212 self.error = Some(FaucetError::Config(format!(
213 "invalid sensitive header name {name:?}"
214 )))
215 }
216 }
217 self
218 }
219
220 pub fn configure(
224 mut self,
225 f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
226 ) -> Self {
227 self.inner = f(self.inner);
228 self
229 }
230
231 pub fn try_configure(
233 mut self,
234 f: impl FnOnce(reqwest::ClientBuilder) -> Result<reqwest::ClientBuilder, FaucetError>,
235 ) -> Result<Self, FaucetError> {
236 self.inner = f(self.inner)?;
237 Ok(self)
238 }
239
240 pub fn build_reqwest(self) -> Result<reqwest::Client, FaucetError> {
245 Ok(self.build()?.inner)
246 }
247
248 pub fn build(self) -> Result<Client, FaucetError> {
250 if let Some(e) = self.error {
251 return Err(e);
252 }
253 Ok(Client {
254 inner: self
255 .inner
256 .redirect(same_origin_redirects(self.max_redirects))
257 .build()?,
258 max_response_bytes: self.max_response_bytes,
259 max_redirects: self.max_redirects,
260 sensitive: self.sensitive.into(),
261 policy: self.policy,
262 })
263 }
264}
265
266fn same_origin_redirects(max: usize) -> reqwest::redirect::Policy {
271 reqwest::redirect::Policy::custom(move |attempt| {
272 let cross = attempt
273 .previous()
274 .last()
275 .is_some_and(|prev| is_cross_origin(prev, attempt.url()));
276 if cross || attempt.previous().len() > max {
277 attempt.stop()
278 } else {
279 attempt.follow()
280 }
281 })
282}
283
284#[derive(Clone)]
287pub struct Client {
288 inner: reqwest::Client,
289 max_response_bytes: Option<u64>,
290 max_redirects: usize,
291 sensitive: Arc<[HeaderName]>,
292 policy: Option<Arc<ReferencePolicy>>,
293}
294
295tokio::task_local! {
296 static SCOPED_POLICY: Arc<ReferencePolicy>;
297}
298
299pub async fn with_reference_policy<F: std::future::Future>(
305 policy: Arc<ReferencePolicy>,
306 fut: F,
307) -> F::Output {
308 SCOPED_POLICY.scope(policy, fut).await
309}
310
311pub fn current_reference_policy() -> Option<Arc<ReferencePolicy>> {
313 SCOPED_POLICY.try_with(Arc::clone).ok()
314}
315
316pub fn inherit_reference_policy<F: std::future::Future>(
319 fut: F,
320) -> impl std::future::Future<Output = F::Output> {
321 let policy = current_reference_policy();
322 async move {
323 match policy {
324 Some(p) => SCOPED_POLICY.scope(p, fut).await,
325 None => fut.await,
326 }
327 }
328}
329
330fn guard_target(
334 policy: Option<&ReferencePolicy>,
335 url: &Url,
336 hop: usize,
337) -> Result<(), FaucetError> {
338 let shown = || crate::util::redact_uri_credentials(url.as_str());
339 let refused = |reason: String| {
340 let what = if hop == 0 { "request" } else { "redirect" };
341 FaucetError::Config(format!("{what} to {} refused: {reason}", shown()))
342 };
343 match policy {
344 Some(p) => p
345 .check_url(url.as_str())
346 .map_err(|e| refused(format!("{} (reference policy)", e.reason))),
347 None if hop > 0 && url.host_str().is_some_and(crate::resolve::is_metadata_host) => Err(
348 refused("the target is a link-local / cloud-metadata address".into()),
349 ),
350 None => Ok(()),
351 }
352}
353
354impl fmt::Debug for Client {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 f.debug_struct("Client")
357 .field("max_response_bytes", &self.max_response_bytes)
358 .field("max_redirects", &self.max_redirects)
359 .field("sensitive", &self.sensitive)
360 .finish_non_exhaustive()
361 }
362}
363
364impl Client {
365 pub fn new() -> Result<Self, FaucetError> {
367 ClientBuilder::new().build()
368 }
369
370 pub fn builder() -> ClientBuilder {
372 ClientBuilder::new()
373 }
374
375 pub fn inner(&self) -> &reqwest::Client {
380 &self.inner
381 }
382
383 pub fn request<U: reqwest::IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
385 self.inner.request(method, url)
386 }
387
388 pub fn get<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
390 self.inner.get(url)
391 }
392
393 pub fn post<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
395 self.inner.post(url)
396 }
397
398 pub fn put<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
400 self.inner.put(url)
401 }
402
403 pub fn delete<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
405 self.inner.delete(url)
406 }
407
408 pub fn head<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
410 self.inner.head(url)
411 }
412
413 pub fn max_response_bytes(&self) -> Option<u64> {
415 self.max_response_bytes
416 }
417
418 pub async fn send(&self, request: RequestBuilder) -> Result<Response, FaucetError> {
421 let request = request.build().map_err(transport_error)?;
422 self.send_request(request).await
423 }
424
425 pub async fn send_request(&self, request: Request) -> Result<Response, FaucetError> {
427 let policy = self.policy.clone().or_else(current_reference_policy);
428 let mut current = request;
429 let mut hops = 0usize;
430 loop {
431 guard_target(policy.as_deref(), current.url(), hops)?;
432 let template = current.try_clone();
433 let method = current.method().clone();
434 let url = current.url().clone();
435 let headers = current.headers().clone();
436 let resp = self.inner.execute(current).await.map_err(transport_error)?;
437 let status = resp.status();
438 if !status.is_redirection() || self.max_redirects == 0 {
439 return Ok(resp);
440 }
441 let Some(next_url) = resp
442 .headers()
443 .get(header::LOCATION)
444 .and_then(|v| v.to_str().ok())
445 .and_then(|loc| url.join(loc).ok())
446 else {
447 return Ok(resp);
448 };
449 let from = resp.url().clone();
450 let same_origin = !is_cross_origin(&from, &next_url);
453 let rewritten = from != url && !matches!(method, Method::GET | Method::HEAD);
454 if (same_origin && template.is_none()) || (!same_origin && rewritten) {
455 return Ok(resp);
456 }
457 if same_origin || hops >= self.max_redirects {
458 return Err(FaucetError::HttpStatus {
459 status: status.as_u16(),
460 url: crate::util::redact_uri_credentials(url.as_str()),
461 body: format!("too many redirects (more than {})", self.max_redirects),
462 });
463 }
464 let Some(next) = redirect_request(
465 status,
466 method,
467 &from,
468 next_url,
469 headers,
470 template,
471 &self.sensitive,
472 ) else {
473 return Ok(resp);
474 };
475 hops += 1;
476 current = next;
477 }
478 }
479
480 pub async fn execute<F>(
485 &self,
486 ctx: &CallContext,
487 op: &'static str,
488 mut build: F,
489 ) -> Result<Response, FaucetError>
490 where
491 F: FnMut() -> RequestBuilder,
492 {
493 ctx.call(op, || {
494 let request = build();
495 async move {
496 let resp = self.send(request).await?;
497 if resp.status().is_success() {
498 Ok(resp)
499 } else {
500 Err(status_error(resp).await)
501 }
502 }
503 })
504 .await
505 }
506
507 pub async fn call(
512 &self,
513 ctx: &CallContext,
514 op: &'static str,
515 request: RequestBuilder,
516 ) -> Result<Response, FaucetError> {
517 let mut original = Some(request.build().map_err(transport_error)?);
518 ctx.call(op, || {
519 let attempt = original
520 .as_ref()
521 .and_then(Request::try_clone)
522 .or_else(|| original.take());
523 async move {
524 match attempt {
525 Some(r) => self.send_request(r).await,
526 None => Err(FaucetError::Custom(Box::new(std::io::Error::other(
527 "a streamed request body cannot be sent again",
528 )))),
529 }
530 }
531 })
532 .await
533 }
534
535 pub async fn bytes(&self, resp: Response) -> Result<Vec<u8>, FaucetError> {
537 read_limited(resp, self.max_response_bytes).await
538 }
539
540 pub async fn text(&self, resp: Response) -> Result<String, FaucetError> {
542 let bytes = self.bytes(resp).await?;
543 Ok(match String::from_utf8(bytes) {
544 Ok(s) => s,
545 Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
546 })
547 }
548
549 pub async fn json<T: DeserializeOwned>(&self, resp: Response) -> Result<T, FaucetError> {
551 let bytes = self.bytes(resp).await?;
552 Ok(serde_json::from_slice(&bytes)?)
553 }
554
555 pub async fn json_value(&self, resp: Response) -> Result<serde_json::Value, FaucetError> {
559 let bytes = self.bytes(resp).await?;
560 Ok(crate::fidelity::from_slice(&bytes)?)
561 }
562}
563
564fn redirect_request(
568 status: StatusCode,
569 method: Method,
570 from: &Url,
571 to: Url,
572 mut headers: HeaderMap,
573 template: Option<Request>,
574 sensitive: &[HeaderName],
575) -> Option<Request> {
576 let to_get = status == StatusCode::SEE_OTHER
577 || ((status == StatusCode::MOVED_PERMANENTLY || status == StatusCode::FOUND)
578 && method == Method::POST);
579 let cross = is_cross_origin(from, &to);
580 if cross {
581 strip_credentials(&mut headers, sensitive);
582 }
583 let mut next = if to_get {
584 for h in [
585 header::CONTENT_TYPE,
586 header::CONTENT_LENGTH,
587 header::CONTENT_ENCODING,
588 header::TRANSFER_ENCODING,
589 ] {
590 headers.remove(h);
591 }
592 let method = if method == Method::HEAD {
593 Method::HEAD
594 } else {
595 Method::GET
596 };
597 Request::new(method, to)
598 } else {
599 let mut next = template?;
600 if cross && next.body().is_some() {
601 return None;
602 }
603 *next.url_mut() = to;
604 next
605 };
606 *next.headers_mut() = headers;
607 Some(next)
608}
609
610pub fn is_cross_origin(a: &Url, b: &Url) -> bool {
612 a.scheme() != b.scheme()
613 || a.host_str() != b.host_str()
614 || a.port_or_known_default() != b.port_or_known_default()
615}
616
617pub fn strip_credentials(headers: &mut HeaderMap, extra: &[HeaderName]) {
620 for h in [
621 header::AUTHORIZATION,
622 header::COOKIE,
623 header::PROXY_AUTHORIZATION,
624 header::WWW_AUTHENTICATE,
625 ] {
626 headers.remove(h);
627 }
628 for h in extra {
629 headers.remove(h);
630 }
631 let marked: Vec<HeaderName> = headers
632 .iter()
633 .filter(|(_, v)| v.is_sensitive())
634 .map(|(k, _)| k.clone())
635 .collect();
636 for h in marked {
637 headers.remove(h);
638 }
639}
640
641pub fn transport_error(e: reqwest::Error) -> FaucetError {
643 FaucetError::Http(crate::io::redact_reqwest_url(e, crate::redact::redact))
644}
645
646pub async fn status_error(resp: Response) -> FaucetError {
652 let status = resp.status().as_u16();
653 let url = resp.url().to_string();
654 match crate::util::check_http_response_rate_limited(
655 resp,
656 crate::util::DEFAULT_ERROR_BODY_MAX_LEN,
657 )
658 .await
659 {
660 Err(FaucetError::HttpStatus { status, url, body }) => FaucetError::HttpStatus {
661 status,
662 url: crate::util::redact_uri_credentials(&url),
663 body,
664 },
665 Err(e) => e,
666 Ok(_) => FaucetError::HttpStatus {
667 status,
668 url: crate::util::redact_uri_credentials(&url),
669 body: String::new(),
670 },
671 }
672}
673
674async fn read_limited(mut resp: Response, limit: Option<u64>) -> Result<Vec<u8>, FaucetError> {
675 let too_large = |resp: &Response, limit: u64| {
676 FaucetError::Custom(Box::new(ResponseTooLarge {
677 url: crate::util::redact_uri_credentials(resp.url().as_str()),
678 limit,
679 }))
680 };
681 if let (Some(limit), Some(len)) = (limit, resp.content_length())
682 && len > limit
683 {
684 return Err(too_large(&resp, limit));
685 }
686 let capacity = resp
687 .content_length()
688 .map_or(0, |n| usize::try_from(n).unwrap_or(0).min(64 << 20));
689 let mut out = Vec::with_capacity(capacity);
690 while let Some(chunk) = resp.chunk().await.map_err(transport_error)? {
691 out.extend_from_slice(&chunk);
692 if let Some(limit) = limit
693 && out.len() as u64 > limit
694 {
695 return Err(too_large(&resp, limit));
696 }
697 }
698 Ok(out)
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::resilience::{BackoffKind, RetryPolicy};
705 use axum::Router;
706 use axum::http::{HeaderMap as AxHeaders, StatusCode as AxStatus};
707 use axum::response::IntoResponse;
708 use axum::routing::{any, get};
709 use std::sync::Mutex;
710
711 type Seen = Arc<Mutex<Vec<(String, Vec<(String, String)>)>>>;
712
713 async fn serve(router: Router) -> String {
714 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
715 let addr = listener.local_addr().unwrap();
716 tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
717 format!("http://{addr}")
718 }
719
720 fn recorder(
721 seen: Seen,
722 name: &'static str,
723 ) -> impl Fn(AxHeaders) -> std::future::Ready<&'static str> + Clone + Send + Sync + 'static
724 {
725 move |h: AxHeaders| {
726 let pairs = h
727 .iter()
728 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
729 .collect();
730 seen.lock().unwrap().push((name.to_string(), pairs));
731 std::future::ready("ok")
732 }
733 }
734
735 fn has(seen: &Seen, hop: &str, header: &str) -> bool {
736 seen.lock()
737 .unwrap()
738 .iter()
739 .filter(|(n, _)| n == hop)
740 .any(|(_, h)| h.iter().any(|(k, _)| k == header))
741 }
742
743 #[tokio::test]
744 async fn a_cross_origin_redirect_drops_credentials() {
745 let seen: Seen = Arc::default();
746 let other = serve(Router::new().route("/land", get(recorder(seen.clone(), "other")))).await;
747 let to = format!("{other}/land");
748 let keep_to = to.clone();
749 let same_seen = seen.clone();
750 let origin =
751 serve(
752 Router::new()
753 .route(
754 "/start",
755 get(move || {
756 let to = to.clone();
757 async move { (AxStatus::FOUND, [("location", to)]).into_response() }
758 }),
759 )
760 .route(
761 "/keep",
762 any(move || {
763 let to = keep_to.clone();
764 async move {
765 (AxStatus::TEMPORARY_REDIRECT, [("location", to)]).into_response()
766 }
767 }),
768 )
769 .route(
770 "/same",
771 get(|| async {
772 (AxStatus::FOUND, [("location", "/here")]).into_response()
773 }),
774 )
775 .route("/here", get(recorder(same_seen, "same"))),
776 )
777 .await;
778 let client = Client::builder()
779 .sensitive_header("x-api-key")
780 .build()
781 .unwrap();
782 let mut secret = reqwest::header::HeaderValue::from_static("s3cr3t");
783 secret.set_sensitive(true);
784 let req = |path: &str| {
785 client
786 .get(format!("{origin}{path}"))
787 .bearer_auth("tok")
788 .header("x-api-key", "k")
789 .header("x-session", secret.clone())
790 .header("cookie", "a=b")
791 .header("x-trace", "t")
792 };
793 let resp = client.send(req("/start")).await.unwrap();
794 assert_eq!(resp.status(), 200);
795 assert!(resp.url().as_str().ends_with("/land"));
796 for h in ["authorization", "x-api-key", "x-session", "cookie"] {
797 assert!(!has(&seen, "other", h), "{h} leaked cross-origin");
798 }
799 assert!(has(&seen, "other", "x-trace"));
800 let resp = client
801 .send(
802 client
803 .post(format!("{origin}/keep"))
804 .body("client_secret=s"),
805 )
806 .await
807 .unwrap();
808 assert_eq!(
809 resp.status(),
810 307,
811 "a body is not replayed to another origin"
812 );
813 client.send(req("/same")).await.unwrap();
814 for h in ["authorization", "x-api-key", "x-session", "cookie"] {
815 assert!(has(&seen, "same", h), "{h} kept on a same-origin hop");
816 }
817 }
818
819 fn redirector(to: String) -> Router {
820 Router::new()
821 .route(
822 "/go",
823 get(move || {
824 let to = to.clone();
825 async move { (AxStatus::SEE_OTHER, [("location", to)]).into_response() }
826 }),
827 )
828 .route("/land", get(|| async { "landed" }))
829 }
830
831 #[tokio::test]
832 async fn a_redirect_into_instance_metadata_is_always_refused() {
833 for target in [
834 "http://169.254.169.254/latest/meta-data",
835 "http://2852039166/latest",
836 "http://[fe80::1]/x",
837 "http://metadata.google.internal./x",
838 ] {
839 let base = serve(redirector(target.to_string())).await;
840 let client = Client::new().unwrap();
841 let err = client
842 .send(client.get(format!("{base}/go")))
843 .await
844 .unwrap_err();
845 assert!(
846 matches!(&err, FaucetError::Config(m) if m.starts_with("redirect to") && m.contains("metadata")),
847 "{target}: {err}"
848 );
849 }
850 }
851
852 #[tokio::test]
853 async fn a_spawned_task_inherits_the_scoped_policy_only_when_asked() {
854 let policy = Arc::new(ReferencePolicy::deny_all());
855 let (inherited, plain) = with_reference_policy(policy, async {
856 let inherited = tokio::spawn(inherit_reference_policy(async {
857 current_reference_policy().is_some()
858 }));
859 let plain = tokio::spawn(async { current_reference_policy().is_some() });
860 (inherited.await.unwrap(), plain.await.unwrap())
861 })
862 .await;
863 assert!(inherited);
864 assert!(!plain);
865 assert!(!inherit_reference_policy(async { current_reference_policy().is_some() }).await);
866 }
867
868 #[tokio::test]
869 async fn a_policy_checks_the_first_request_and_every_redirect_hop() {
870 let land = serve(Router::new().route("/land", get(|| async { "landed" }))).await;
871 let port = land.rsplit(':').next().unwrap().to_string();
872 let to_localhost = format!("http://localhost:{port}/land");
873 let base = serve(redirector(to_localhost.clone())).await;
874 let policy = Arc::new(ReferencePolicy::open().with_url_hosts(["127.0.0.1"]));
875 let client = Client::builder()
876 .reference_policy(policy.clone())
877 .build()
878 .unwrap();
879 let err = client
880 .send(client.get(format!("{base}/go")))
881 .await
882 .unwrap_err();
883 assert!(
884 matches!(&err, FaucetError::Config(m) if m.contains("host `localhost` not in the allow-list")),
885 "{err}"
886 );
887 let err = client.send(client.get(&to_localhost)).await.unwrap_err();
888 assert!(
889 matches!(&err, FaucetError::Config(m) if m.starts_with("request to")),
890 "{err}"
891 );
892 let allowed = serve(redirector(format!("{land}/land"))).await;
893 let resp = client
894 .send(client.get(format!("{allowed}/go")))
895 .await
896 .unwrap();
897 assert_eq!(client.text(resp).await.unwrap(), "landed");
898
899 let plain = Client::new().unwrap();
900 let scoped = with_reference_policy(policy.clone(), async {
901 assert!(current_reference_policy().is_some());
902 plain.send(plain.get(format!("{base}/go"))).await
903 })
904 .await;
905 assert!(scoped.is_err());
906 assert!(current_reference_policy().is_none());
907 let resp = plain.send(plain.get(format!("{base}/go"))).await.unwrap();
908 assert_eq!(resp.status(), 200);
909 let meta = Arc::new(ReferencePolicy::open().with_url_hosts(["169.254.169.254"]));
910 assert!(
911 guard_target(
912 Some(&meta),
913 &Url::parse("http://169.254.169.254/").unwrap(),
914 1
915 )
916 .is_ok()
917 );
918 }
919
920 #[tokio::test]
921 async fn redirects_rewrite_methods_and_respect_the_limit() {
922 let seen: Arc<Mutex<Vec<String>>> = Arc::default();
923 let s = seen.clone();
924 let base = serve(
925 Router::new()
926 .route(
927 "/see-other",
928 any(|| async {
929 (AxStatus::SEE_OTHER, [("location", "/sink")]).into_response()
930 }),
931 )
932 .route(
933 "/temp",
934 any(|| async {
935 (AxStatus::TEMPORARY_REDIRECT, [("location", "/sink")]).into_response()
936 }),
937 )
938 .route(
939 "/loop",
940 any(|| async { (AxStatus::FOUND, [("location", "/loop")]).into_response() }),
941 )
942 .route(
943 "/nowhere",
944 any(|| async { AxStatus::MOVED_PERMANENTLY.into_response() }),
945 )
946 .route(
947 "/sink",
948 any(move |m: axum::http::Method, body: String| {
949 s.lock().unwrap().push(format!("{m} {body}"));
950 async { "done" }
951 }),
952 ),
953 )
954 .await;
955 let client = Client::builder().max_redirects(3).build().unwrap();
956 client
957 .send(client.post(format!("{base}/see-other")).body("payload"))
958 .await
959 .unwrap();
960 client
961 .send(client.post(format!("{base}/temp")).body("payload"))
962 .await
963 .unwrap();
964 assert_eq!(*seen.lock().unwrap(), vec!["GET ", "POST payload"]);
965 let err = client
966 .send(client.get(format!("{base}/loop")))
967 .await
968 .unwrap_err();
969 assert!(
970 matches!(&err, FaucetError::HttpStatus { status: 302, body, .. } if body.contains("too many")),
971 "{err:?}"
972 );
973 let resp = client
974 .send(client.get(format!("{base}/nowhere")))
975 .await
976 .unwrap();
977 assert_eq!(resp.status(), 301, "no Location: returned as is");
978 let none = Client::builder().max_redirects(0).build().unwrap();
979 let resp = none.send(none.get(format!("{base}/loop"))).await.unwrap();
980 assert_eq!(resp.status(), 302);
981 let stream = reqwest::Body::wrap("x".to_string());
982 let resp = client
983 .send(client.post(format!("{base}/temp")).body(stream))
984 .await
985 .unwrap();
986 assert_eq!(resp.status(), 307, "a streamed body cannot be replayed");
987 let resp = client
988 .send(client.head(format!("{base}/see-other")))
989 .await
990 .unwrap();
991 assert_eq!(resp.status(), 200);
992 }
993
994 #[tokio::test]
995 async fn reads_are_bounded() {
996 let base = serve(
997 Router::new()
998 .route("/big", get(|| async { "0123456789" }))
999 .route(
1000 "/chunked",
1001 get(|| async {
1002 axum::body::Body::from_stream(futures::stream::iter(vec![
1003 Ok::<_, std::io::Error>("01234"),
1004 Ok("56789"),
1005 ]))
1006 }),
1007 )
1008 .route("/json", get(|| async { r#"{"a":1}"# }))
1009 .route("/latin", get(|| async { vec![0xffu8, b'a'] })),
1010 )
1011 .await;
1012 let small = Client::builder()
1013 .max_response_bytes(Some(4))
1014 .build()
1015 .unwrap();
1016 for path in ["/big", "/chunked"] {
1017 let resp = small
1018 .send(small.get(format!("{base}{path}")))
1019 .await
1020 .unwrap();
1021 let err = small.bytes(resp).await.unwrap_err();
1022 let FaucetError::Custom(inner) = &err else {
1023 panic!("{err:?}")
1024 };
1025 let tl = inner.downcast_ref::<ResponseTooLarge>().unwrap();
1026 assert_eq!(tl.limit, 4);
1027 assert!(tl.to_string().contains("4-byte limit"));
1028 }
1029 let client = Client::new().unwrap();
1030 assert_eq!(
1031 client.max_response_bytes(),
1032 Some(DEFAULT_MAX_RESPONSE_BYTES)
1033 );
1034 let resp = client
1035 .send(client.get(format!("{base}/json")))
1036 .await
1037 .unwrap();
1038 let v: serde_json::Value = client.json(resp).await.unwrap();
1039 assert_eq!(v["a"], 1);
1040 let resp = client
1041 .send(client.get(format!("{base}/latin")))
1042 .await
1043 .unwrap();
1044 assert_eq!(client.text(resp).await.unwrap(), "\u{fffd}a");
1045 let unbounded = Client::builder().max_response_bytes(None).build().unwrap();
1046 let resp = unbounded
1047 .send(unbounded.get(format!("{base}/chunked")))
1048 .await
1049 .unwrap();
1050 assert_eq!(unbounded.text(resp).await.unwrap(), "0123456789");
1051 }
1052
1053 #[tokio::test]
1054 async fn execute_retries_and_classifies_statuses() {
1055 let hits = Arc::new(std::sync::atomic::AtomicU32::new(0));
1056 let h = hits.clone();
1057 let base = serve(
1058 Router::new()
1059 .route(
1060 "/flaky",
1061 get(move || {
1062 let n = h.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1063 async move {
1064 if n == 0 {
1065 AxStatus::BAD_GATEWAY.into_response()
1066 } else {
1067 "fine".into_response()
1068 }
1069 }
1070 }),
1071 )
1072 .route(
1073 "/limited",
1074 get(|| async {
1075 (AxStatus::TOO_MANY_REQUESTS, [("retry-after", "3")]).into_response()
1076 }),
1077 )
1078 .route(
1079 "/missing",
1080 get(|| async { (AxStatus::NOT_FOUND, "nope").into_response() }),
1081 ),
1082 )
1083 .await;
1084 let client = Client::new().unwrap();
1085 let ctx = CallContext::new().with_retry(RetryPolicy {
1086 max_attempts: 3,
1087 backoff: BackoffKind::None,
1088 jitter: false,
1089 ..RetryPolicy::default()
1090 });
1091 let resp = client
1092 .execute(&ctx, "get", || client.get(format!("{base}/flaky")))
1093 .await
1094 .unwrap();
1095 assert_eq!(client.text(resp).await.unwrap(), "fine");
1096 assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 2);
1097 let err = client
1098 .execute(&CallContext::new(), "get", || {
1099 client.get(format!("{base}/limited"))
1100 })
1101 .await
1102 .unwrap_err();
1103 assert!(
1104 matches!(err, FaucetError::RateLimited(d) if d == Duration::from_secs(3)),
1105 "{err:?}"
1106 );
1107 let err = client
1108 .execute(&ctx, "get", || {
1109 client.get(format!("http://u:pw@{}/missing", &base[7..]))
1110 })
1111 .await
1112 .unwrap_err();
1113 match err {
1114 FaucetError::HttpStatus { status, url, body } => {
1115 assert_eq!(status, 404);
1116 assert!(!url.contains("pw"), "{url}");
1117 assert_eq!(body, "nope");
1118 }
1119 other => panic!("{other:?}"),
1120 }
1121 }
1122
1123 #[tokio::test]
1124 async fn transport_errors_keep_their_class() {
1125 let client = Client::builder()
1126 .timeout(Duration::from_millis(50))
1127 .connect_timeout(Duration::from_millis(50))
1128 .read_timeout(Duration::from_millis(50))
1129 .user_agent("faucet-test")
1130 .default_headers(HeaderMap::new())
1131 .no_proxy()
1132 .configure(|b| b.pool_max_idle_per_host(1))
1133 .try_configure(Ok)
1134 .unwrap()
1135 .build()
1136 .unwrap();
1137 let base = serve(Router::new().route(
1138 "/slow",
1139 get(|| async {
1140 #[allow(clippy::disallowed_methods)]
1141 tokio::time::sleep(Duration::from_secs(5)).await;
1142 "late"
1143 }),
1144 ))
1145 .await;
1146 let err = client
1147 .send(client.get(format!("{base}/slow")))
1148 .await
1149 .unwrap_err();
1150 assert_eq!(
1151 crate::resilience::classify(&err),
1152 Some(crate::RetryClass::Timeout),
1153 "{err:?}"
1154 );
1155 let err = client
1156 .send(client.get("http://127.0.0.1:1/"))
1157 .await
1158 .unwrap_err();
1159 assert!(crate::resilience::classify(&err).is_some(), "{err:?}");
1160 let err = client.send(client.get("not a url")).await.unwrap_err();
1161 assert!(matches!(err, FaucetError::Http(_)));
1162 for m in [client.put("http://h/"), client.delete("http://h/")] {
1163 assert!(m.build().is_ok());
1164 }
1165 assert!(client.request(Method::PATCH, "http://h/").build().is_ok());
1166 assert!(format!("{client:?}").contains("Client"));
1167 assert!(format!("{:?}", Client::builder()).contains("ClientBuilder"));
1168 assert!(format!("{:?}", ClientBuilder::default()).contains("max_redirects"));
1169 let _ = client.inner();
1170 }
1171
1172 #[test]
1173 fn invalid_builder_input_fails_build() {
1174 assert!(matches!(
1175 Client::builder().proxy("::not a url::").build(),
1176 Err(FaucetError::Config(_))
1177 ));
1178 assert!(matches!(
1179 Client::builder().sensitive_header("bad header").build(),
1180 Err(FaucetError::Config(_))
1181 ));
1182 assert!(
1183 Client::builder()
1184 .proxy("http://proxy.local:3128")
1185 .build()
1186 .is_ok()
1187 );
1188 assert!(Client::builder().build_reqwest().is_ok());
1189 assert!(
1190 Client::builder()
1191 .sensitive_header("bad header")
1192 .build_reqwest()
1193 .is_err()
1194 );
1195 assert!(
1196 Client::builder()
1197 .try_configure(|_| Err(FaucetError::Config("no".into())))
1198 .is_err()
1199 );
1200 }
1201
1202 #[test]
1203 fn origins_compare_scheme_host_and_port() {
1204 let u = |s: &str| Url::parse(s).unwrap();
1205 assert!(!is_cross_origin(&u("https://a/x"), &u("https://a:443/y")));
1206 assert!(is_cross_origin(&u("https://a/x"), &u("http://a/x")));
1207 assert!(is_cross_origin(&u("https://a/x"), &u("https://b/x")));
1208 assert!(is_cross_origin(&u("https://a/x"), &u("https://a:8443/x")));
1209 }
1210
1211 #[tokio::test]
1212 async fn call_returns_any_status_and_resends_only_replayable_bodies() {
1213 let hits = Arc::new(std::sync::atomic::AtomicU32::new(0));
1214 let h = hits.clone();
1215 let base = serve(Router::new().route(
1216 "/x",
1217 any(move || {
1218 h.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1219 async { AxStatus::BAD_GATEWAY.into_response() }
1220 }),
1221 ))
1222 .await;
1223 let client = Client::new().unwrap();
1224 let resp = client
1225 .call(&CallContext::new(), "get", client.get(format!("{base}/x")))
1226 .await
1227 .unwrap();
1228 assert_eq!(resp.status(), 502, "statuses are the caller's to classify");
1229 let ctx = CallContext::new().with_retry(RetryPolicy {
1230 max_attempts: 3,
1231 backoff: BackoffKind::None,
1232 jitter: false,
1233 ..RetryPolicy::default()
1234 });
1235 let resp = client
1236 .call(&ctx, "get", client.get(format!("{base}/x")))
1237 .await
1238 .unwrap();
1239 assert_eq!(resp.status(), 502, "a status is not retried here");
1240 assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 2);
1241 let refused = Client::new().unwrap();
1242 let err = refused
1243 .call(
1244 &ctx,
1245 "post",
1246 refused
1247 .post("http://127.0.0.1:1/x")
1248 .body(reqwest::Body::wrap("s".to_string())),
1249 )
1250 .await
1251 .unwrap_err();
1252 assert!(err.to_string().contains("cannot be sent again"), "{err}");
1253 let err = refused
1254 .call(&ctx, "get", refused.get("http://127.0.0.1:1/x"))
1255 .await
1256 .unwrap_err();
1257 assert!(matches!(err, FaucetError::Http(_)), "{err:?}");
1258 assert!(
1259 refused
1260 .call(&ctx, "get", refused.get("not a url"))
1261 .await
1262 .is_err()
1263 );
1264 }
1265
1266 #[tokio::test]
1267 async fn status_error_without_a_failure_status() {
1268 let base = serve(Router::new().route("/ok", get(|| async { "fine" }))).await;
1269 let client = Client::new().unwrap();
1270 let resp = client.send(client.get(format!("{base}/ok"))).await.unwrap();
1271 assert!(matches!(
1272 status_error(resp).await,
1273 FaucetError::HttpStatus { status: 200, .. }
1274 ));
1275 }
1276}