Skip to main content

faucet_core/
http.rs

1//! The HTTP client every HTTP connector uses (RFC 0013, #880).
2//!
3//! [`Client`] wraps a `reqwest::Client` and adds what each HTTP connector
4//! used to write for itself, sometimes wrongly:
5//!
6//! - **Redirects that drop credentials.** Redirects are followed here, not by
7//!   reqwest, so a hop to another origin (scheme, host or port) drops the
8//!   `Authorization`, `Cookie` and `Proxy-Authorization` headers, every header
9//!   marked sensitive, and every header named with
10//!   [`ClientBuilder::sensitive_header`] (an API-key header). reqwest's own
11//!   policy only knows the standard headers.
12//! - **Redirect targets are checked.** A redirect into a link-local or cloud
13//!   instance-metadata address is always refused. Under a reference policy
14//!   ([`ClientBuilder::reference_policy`], or one scoped over a run with
15//!   [`with_reference_policy`]) the first request and every redirect hop must
16//!   pass [`ReferencePolicy::check_url`](crate::resolve::ReferencePolicy::check_url).
17//! - **Bounded response reads.** [`Client::bytes`] / [`text`](Client::text) /
18//!   [`json`](Client::json) refuse a body above
19//!   [`max_response_bytes`](ClientBuilder::max_response_bytes) instead of
20//!   buffering it whole.
21//! - **One error shape.** A non-2xx response becomes
22//!   [`FaucetError::HttpStatus`] (body truncated, URL without credentials), a
23//!   `429` / `503` with `Retry-After` becomes [`FaucetError::RateLimited`], and
24//!   a transport error keeps reqwest's timeout / connect classification with
25//!   the credentials removed from its URL.
26//! - **Retries through [`CallContext`].** [`Client::execute`] runs a request
27//!   under a context's timeout, deadline, retry policy, cancellation and
28//!   metering.
29//!
30//! ```no_run
31//! # async fn ex() -> Result<(), faucet_core::FaucetError> {
32//! use faucet_core::http::Client;
33//! use faucet_core::io::CallContext;
34//! use std::time::Duration;
35//!
36//! let client = Client::builder()
37//!     .timeout(Duration::from_secs(30))
38//!     .sensitive_header("x-api-key")
39//!     .build()?;
40//! let ctx = CallContext::new().with_retry(faucet_core::RetryPolicy::default());
41//! let resp = client
42//!     .execute(&ctx, "page", || client.get("https://api.example.com/items"))
43//!     .await?;
44//! let body: serde_json::Value = client.json(resp).await?;
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! reqwest options this builder does not name (compression, mutual TLS, root
50//! certificates) are set through [`ClientBuilder::configure`].
51
52use 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
62/// The largest body [`Client::bytes`] buffers by default: 1 GiB.
63pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1 << 30;
64
65/// The most redirects one request follows by default.
66pub const DEFAULT_MAX_REDIRECTS: usize = 10;
67
68/// A response body was larger than the client's
69/// [`max_response_bytes`](ClientBuilder::max_response_bytes). Carried in
70/// [`FaucetError::Custom`]; downcast to read it.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ResponseTooLarge {
73    /// The response URL, without credentials.
74    pub url: String,
75    /// The limit that was exceeded, in bytes.
76    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/// Builds a [`Client`].
92#[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    /// A builder with the defaults: no client-level timeout, a
120    /// [`DEFAULT_MAX_RESPONSE_BYTES`] read limit, [`DEFAULT_MAX_REDIRECTS`].
121    #[allow(clippy::disallowed_methods)] // the one sanctioned reqwest client constructor
122    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    /// Bound a whole request, body included.
134    pub fn timeout(mut self, timeout: Duration) -> Self {
135        self.inner = self.inner.timeout(timeout);
136        self
137    }
138
139    /// Bound connection setup.
140    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
141        self.inner = self.inner.connect_timeout(timeout);
142        self
143    }
144
145    /// Bound each idle read, so a long streamed body is not cut while it keeps
146    /// arriving.
147    pub fn read_timeout(mut self, timeout: Duration) -> Self {
148        self.inner = self.inner.read_timeout(timeout);
149        self
150    }
151
152    /// Send `user_agent` on every request.
153    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    /// Send `headers` on every request.
159    pub fn default_headers(mut self, headers: HeaderMap) -> Self {
160        self.inner = self.inner.default_headers(headers);
161        self
162    }
163
164    /// Route every request through the proxy at `url`. A malformed URL fails
165    /// [`build`](Self::build).
166    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    /// Ignore proxy environment variables.
180    pub fn no_proxy(mut self) -> Self {
181        self.inner = self.inner.no_proxy();
182        self
183    }
184
185    /// Follow at most `n` redirects (`0` returns the redirect response).
186    pub fn max_redirects(mut self, n: usize) -> Self {
187        self.max_redirects = n;
188        self
189    }
190
191    /// Refuse a buffered body above `limit` bytes; `None` lifts the limit.
192    pub fn max_response_bytes(mut self, limit: Option<u64>) -> Self {
193        self.max_response_bytes = limit;
194        self
195    }
196
197    /// Check the first request and every redirect hop [`send`](Client::send)
198    /// follows against `policy`, instead of the one scoped over the task
199    /// ([`with_reference_policy`]).
200    pub fn reference_policy(mut self, policy: Arc<ReferencePolicy>) -> Self {
201        self.policy = Some(policy);
202        self
203    }
204
205    /// Drop the header `name` on a redirect to another origin, like
206    /// `Authorization`. Name every header that carries a credential (an API
207    /// key, a session id). An invalid name fails [`build`](Self::build).
208    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    /// Set any other reqwest option: `|b| b.gzip(true)`, an identity, root
221    /// certificates. Don't set a redirect policy here — redirects are followed
222    /// by [`Client::send`], which strips credentials on a cross-origin hop.
223    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    /// Like [`configure`](Self::configure) for a step that can fail.
232    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    /// Build a plain `reqwest::Client` with these settings, for a connector
241    /// whose public API already hands out a reqwest client. It follows
242    /// same-origin redirects only; prefer [`build`](Self::build), which also
243    /// follows cross-origin hops safely and bounds reads.
244    pub fn build_reqwest(self) -> Result<reqwest::Client, FaucetError> {
245        Ok(self.build()?.inner)
246    }
247
248    /// Build the client.
249    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
266/// reqwest's own redirect policy for a [`Client`]: follow a same-origin hop
267/// up to `max`, stop at a hop to another origin. [`Client::send`] follows that
268/// one with the credentials stripped; a request sent straight from a
269/// [`RequestBuilder`] gets the 3xx back, so nothing leaks either way.
270fn 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/// An HTTP client for connectors. Cheap to clone; build one in `new()` and
285/// reuse it. See the [module docs](self).
286#[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
299/// Run `fut` with `policy` as the reference policy every [`Client`] without
300/// its own ([`ClientBuilder::reference_policy`]) checks its requests and
301/// redirect hops against. A host scopes the policy that admitted a config
302/// over that config's run; a task spawned from `fut` must be wrapped again
303/// ([`current_reference_policy`]).
304pub 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
311/// The reference policy scoped over the current task, if any.
312pub fn current_reference_policy() -> Option<Arc<ReferencePolicy>> {
313    SCOPED_POLICY.try_with(Arc::clone).ok()
314}
315
316/// `fut` under the reference policy scoped over the current task, for a
317/// future about to be spawned onto another task.
318pub 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
330/// Refuse a request to `url`. Under a policy, `url` must pass it; without
331/// one, a redirect hop (`hop > 0`) may not reach a link-local or metadata
332/// address — a redirect there is never legitimate.
333fn 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    /// A client with the [`ClientBuilder`] defaults.
366    pub fn new() -> Result<Self, FaucetError> {
367        ClientBuilder::new().build()
368    }
369
370    /// A builder.
371    pub fn builder() -> ClientBuilder {
372        ClientBuilder::new()
373    }
374
375    /// The underlying reqwest client, for request shapes this type does not
376    /// cover. It follows same-origin redirects only: a request sent straight
377    /// from it gets a cross-origin 3xx back instead of following it. Send
378    /// through [`send`](Self::send) to follow those with credentials stripped.
379    pub fn inner(&self) -> &reqwest::Client {
380        &self.inner
381    }
382
383    /// Start a request.
384    pub fn request<U: reqwest::IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
385        self.inner.request(method, url)
386    }
387
388    /// Start a `GET`.
389    pub fn get<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
390        self.inner.get(url)
391    }
392
393    /// Start a `POST`.
394    pub fn post<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
395        self.inner.post(url)
396    }
397
398    /// Start a `PUT`.
399    pub fn put<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
400        self.inner.put(url)
401    }
402
403    /// Start a `DELETE`.
404    pub fn delete<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
405        self.inner.delete(url)
406    }
407
408    /// Start a `HEAD`.
409    pub fn head<U: reqwest::IntoUrl>(&self, url: U) -> RequestBuilder {
410        self.inner.head(url)
411    }
412
413    /// The buffered-read limit.
414    pub fn max_response_bytes(&self) -> Option<u64> {
415        self.max_response_bytes
416    }
417
418    /// Send one attempt and follow its redirects. Any status comes back as a
419    /// response; only a transport failure is an error.
420    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    /// [`send`](Self::send) for an already-built request.
426    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            // reqwest follows same-origin hops itself and stops only at the
451            // limit or on a body it cannot replay.
452            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    /// Run a request as one backend call under `ctx` ([`CallContext::call`]):
481    /// `build` is invoked once per attempt, a non-2xx response is an error
482    /// ([`status_error`]) so a `5xx` / `429` is retried by the context's
483    /// policy, and the 2xx response is returned.
484    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    /// Send `request` as one backend call under `ctx` and return the response
508    /// whatever its status — for a connector that classifies statuses itself.
509    /// Attempts are retried under the context's policy only while the request
510    /// can be re-sent; a streamed body goes out once.
511    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    /// Read the whole body, refusing one above the read limit.
536    pub async fn bytes(&self, resp: Response) -> Result<Vec<u8>, FaucetError> {
537        read_limited(resp, self.max_response_bytes).await
538    }
539
540    /// Read the whole body as text (invalid UTF-8 replaced).
541    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    /// Read the whole body as JSON.
550    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    /// A JSON response body as a `Value` with exact numbers: a literal the
556    /// value would not spell again stays a string of its digits
557    /// ([`crate::fidelity::from_slice`]).
558    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
564/// The request for the next redirect hop, or `None` when the hop is not
565/// followed: a streamed body cannot be replayed, and a body is never replayed
566/// to another origin (it may carry credentials, like a token form).
567fn 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
610/// Whether `b` is a different origin from `a`: another scheme, host or port.
611pub 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
617/// Remove every credential-bearing header: the standard ones, every value
618/// marked sensitive, and the `extra` names.
619pub 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
641/// A transport error with the credentials removed from its URL.
642pub fn transport_error(e: reqwest::Error) -> FaucetError {
643    FaucetError::Http(crate::io::redact_reqwest_url(e, crate::redact::redact))
644}
645
646/// The error for a non-2xx response: [`FaucetError::RateLimited`] for a `429`
647/// (or a `503` with `Retry-After`) that states its wait, otherwise
648/// [`FaucetError::HttpStatus`] with the body truncated to
649/// [`DEFAULT_ERROR_BODY_MAX_LEN`](crate::util::DEFAULT_ERROR_BODY_MAX_LEN)
650/// bytes and the URL without credentials. Consumes the response.
651pub 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}