Skip to main content

identity_kit/methods/web/
client.rs

1//! `ReqwestClient`: the hardened default `HttpClient`.
2
3use std::net::{IpAddr, SocketAddr};
4use std::sync::Arc;
5use std::time::Duration;
6
7use url::{Host, Url};
8
9use super::{HttpClient, HttpError, HttpResponse};
10
11/// An [`HttpClient`] over [`reqwest`] hardened for fetching DID documents from the public web.
12///
13/// [`ReqwestClient::new`] builds a client that:
14///
15/// - speaks HTTPS only, with the platform's root certificates and `rustls`;
16/// - follows no redirect, so a document is fetched from the URL its DID maps to and nowhere
17///   else: a 3xx response is returned with its `Location` in [`HttpResponse::location`], for a
18///   caller that follows redirects to fetch each target through this client again;
19/// - sends `Accept` when asked through [`HttpClient::get_accepting`], and
20///   `Accept-Encoding: gzip`, decoding a gzip-encoded body;
21/// - resolves host names itself and refuses loopback, private, link-local and other
22///   non-public addresses, so a DID cannot point the resolver at internal services;
23/// - uses no proxy, whatever `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` or the platform's
24///   settings say: a proxy resolves the host itself, so it would reach the addresses refused
25///   above;
26/// - stops reading a body over [`DEFAULT_MAX_BODY`](Self::DEFAULT_MAX_BODY) bytes, counted
27///   after decoding, so a small compressed body cannot expand past the limit;
28/// - times out after [`DEFAULT_TIMEOUT`](Self::DEFAULT_TIMEOUT).
29///
30/// [`ReqwestClient::from_client`] wraps a client the application configured itself, keeping
31/// only the HTTPS-only rule, the refusal of non-public IP literals and the body limit; the
32/// other protections are then the application's. That is also how to fetch through an egress
33/// proxy: configure it on that client, and have the proxy refuse non-public targets, since it
34/// resolves the host names.
35///
36/// # Local development and tests
37///
38/// With the feature `local-network`, [`ReqwestClient::builder`] can let a client reach
39/// a `did:web` served from the local machine or a private network, such as a test server on
40/// `127.0.0.1` or a development deployment in a docker-compose network. It is for
41/// development and tests only; enable the feature under `[dev-dependencies]`, or behind a
42/// feature of the application's own, so that a release build does not contain it.
43///
44/// - [`dangerous_allow_loopback`](ReqwestClientBuilder::dangerous_allow_loopback) allows
45///   `127.0.0.0/8` and `::1`, and
46///   [`dangerous_allow_private_network`](ReqwestClientBuilder::dangerous_allow_private_network)
47///   allows RFC 1918 and `fc00::/7`. Neither takes a `bool`: the choice is made in code.
48/// - The client resolves the host first. When every address is in an allowed range, it fetches
49///   the URL over plain `http`, on the port the URL names (443 when it names none), connecting
50///   to exactly the addresses it checked. Otherwise the rules above apply unchanged: a host with
51///   public and local addresses is held to them.
52/// - Link-local addresses (including cloud metadata endpoints such as `169.254.169.254`) and
53///   every other non-public range stay refused; redirects, the timeout and the body limit are
54///   unchanged.
55#[derive(Debug, Clone)]
56pub struct ReqwestClient {
57    client: reqwest::Client,
58    max_body: usize,
59    #[cfg(feature = "local-network")]
60    local: LocalNetwork,
61}
62
63impl ReqwestClient {
64    /// The largest body [`new`](Self::new) reads: 1 MiB, far above any DID document.
65    pub const DEFAULT_MAX_BODY: usize = 1 << 20;
66
67    /// The request timeout of [`new`](Self::new).
68    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
69
70    /// A hardened client with the defaults above.
71    ///
72    /// # Errors
73    ///
74    /// [`HttpError`] when the TLS backend cannot be initialized.
75    pub fn new() -> Result<Self, HttpError> {
76        // Make `ring` the process default when nothing chose a provider yet; a provider another
77        // part of the application installed is kept.
78        let _ = rustls::crypto::ring::default_provider().install_default();
79        let client = reqwest::Client::builder()
80            .https_only(true)
81            .redirect(reqwest::redirect::Policy::none())
82            .dns_resolver(Arc::new(PublicAddresses))
83            .no_proxy()
84            .timeout(Self::DEFAULT_TIMEOUT)
85            .user_agent(concat!("identity-kit/", env!("CARGO_PKG_VERSION")))
86            .build()
87            .map_err(HttpError::new)?;
88        Ok(Self {
89            client,
90            max_body: Self::DEFAULT_MAX_BODY,
91            #[cfg(feature = "local-network")]
92            local: LocalNetwork::NONE,
93        })
94    }
95
96    /// A client over `client` as the application configured it, still HTTPS-only, refusing
97    /// non-public IP literals, and reading at most [`DEFAULT_MAX_BODY`](Self::DEFAULT_MAX_BODY)
98    /// bytes of body.
99    ///
100    /// Host names are resolved as `client` resolves them, so refusing the non-public
101    /// addresses they resolve to is the application's: with a DNS resolver like the one
102    /// [`new`](Self::new) installs, and, when `client` uses a proxy, by the proxy itself,
103    /// which resolves the host names it is asked to reach.
104    pub const fn from_client(client: reqwest::Client) -> Self {
105        Self {
106            client,
107            max_body: Self::DEFAULT_MAX_BODY,
108            #[cfg(feature = "local-network")]
109            local: LocalNetwork::NONE,
110        }
111    }
112
113    /// A builder for a client that may also reach the local network, for development and
114    /// tests; see [Local development and tests](Self#local-development-and-tests).
115    #[cfg(feature = "local-network")]
116    #[cfg_attr(docsrs, doc(cfg(feature = "local-network")))]
117    pub const fn builder() -> ReqwestClientBuilder {
118        ReqwestClientBuilder {
119            local: LocalNetwork::NONE,
120        }
121    }
122
123    /// This client reading at most `max_body` bytes of body.
124    #[must_use]
125    pub const fn with_max_body(mut self, max_body: usize) -> Self {
126        self.max_body = max_body;
127        self
128    }
129
130    /// The body limit.
131    pub const fn max_body(&self) -> usize {
132        self.max_body
133    }
134
135    /// The underlying client.
136    pub const fn client(&self) -> &reqwest::Client {
137        &self.client
138    }
139}
140
141#[async_trait::async_trait]
142impl HttpClient for ReqwestClient {
143    async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
144        self.fetch(url, None).await
145    }
146
147    async fn get_accepting(&self, url: &Url, media_type: &str) -> Result<HttpResponse, HttpError> {
148        self.fetch(url, Some(media_type)).await
149    }
150}
151
152impl ReqwestClient {
153    /// Fetches `url`, sending `accept` as the `Accept` header when given.
154    async fn fetch(&self, url: &Url, accept: Option<&str>) -> Result<HttpResponse, HttpError> {
155        #[cfg(feature = "local-network")]
156        if let Some(response) = self.get_local(url, accept).await? {
157            return Ok(response);
158        }
159        if url.scheme() != "https" {
160            return Err(HttpError::msg(format!("`{url}` is not an HTTPS URL")));
161        }
162        // An IP literal never reaches the DNS resolver, so it is checked here.
163        let literal = match url.host() {
164            Some(Host::Ipv4(ip)) => Some(IpAddr::V4(ip)),
165            Some(Host::Ipv6(ip)) => Some(IpAddr::V6(ip)),
166            _ => None,
167        };
168        if literal.is_some_and(|ip| !is_public(ip)) {
169            return Err(HttpError::msg(format!("`{url}` is not a public address")));
170        }
171        let response = request(&self.client, url, accept)
172            .send()
173            .await
174            .map_err(HttpError::new)?;
175        read(response, self.max_body).await
176    }
177}
178
179/// A `GET` of `url` with `accept` as the `Accept` header, when given.
180fn request(client: &reqwest::Client, url: &Url, accept: Option<&str>) -> reqwest::RequestBuilder {
181    let request = client.get(url.as_str());
182    match accept {
183        Some(media_type) => request.header(reqwest::header::ACCEPT, media_type),
184        None => request,
185    }
186}
187
188/// The status, the `Location` of a redirect and at most `max_body` bytes of decoded body of
189/// `response`.
190async fn read(mut response: reqwest::Response, max_body: usize) -> Result<HttpResponse, HttpError> {
191    let status = response.status().as_u16();
192    let location = if response.status().is_redirection() {
193        response
194            .headers()
195            .get(reqwest::header::LOCATION)
196            .and_then(|location| location.to_str().ok())
197            .map(str::to_owned)
198    } else {
199        None
200    };
201    if response
202        .content_length()
203        .is_some_and(|len| len > max_body as u64)
204    {
205        return Err(HttpError::msg(format!(
206            "response body exceeds {max_body} bytes"
207        )));
208    }
209    let mut body = Vec::new();
210    while let Some(chunk) = response.chunk().await.map_err(HttpError::new)? {
211        if body.len() + chunk.len() > max_body {
212            return Err(HttpError::msg(format!(
213                "response body exceeds {max_body} bytes"
214            )));
215        }
216        body.extend_from_slice(&chunk);
217    }
218    let response = HttpResponse::new(status, body);
219    Ok(match location {
220        Some(location) => response.with_location(location),
221        None => response,
222    })
223}
224
225#[cfg(feature = "local-network")]
226pub use local::{LocalNetwork, ReqwestClientBuilder};
227
228#[cfg(feature = "local-network")]
229mod local {
230    use std::net::{IpAddr, SocketAddr};
231
232    use url::{Host, Url};
233
234    use super::{HttpError, HttpResponse, ReqwestClient, is_public, read, request};
235
236    /// The local address ranges a [`ReqwestClient`] may reach besides the public internet.
237    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
238    #[non_exhaustive]
239    pub struct LocalNetwork {
240        /// `127.0.0.0/8` and `::1`.
241        pub loopback: bool,
242        /// RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) and `fc00::/7`.
243        pub private: bool,
244    }
245
246    impl LocalNetwork {
247        /// No local range: the public rules only.
248        pub const NONE: Self = Self {
249            loopback: false,
250            private: false,
251        };
252
253        /// Whether `ip` is in one of the allowed local ranges. Link-local and every other
254        /// non-public range never are.
255        pub fn allows(&self, ip: IpAddr) -> bool {
256            let ip = match ip {
257                IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
258                IpAddr::V4(_) => ip,
259            };
260            let loopback = ip.is_loopback();
261            let private = match ip {
262                IpAddr::V4(v4) => v4.is_private(),
263                IpAddr::V6(v6) => v6.is_unique_local(),
264            };
265            (self.loopback && loopback) || (self.private && private)
266        }
267
268        /// Whether every one of `addresses`, of which there is at least one, is in an allowed
269        /// local range: the host is fetched over plain `http`. Otherwise the public rules
270        /// apply.
271        pub fn all_local(&self, addresses: &[IpAddr]) -> bool {
272            !addresses.is_empty() && addresses.iter().all(|ip| self.allows(*ip))
273        }
274    }
275
276    /// Builds a [`ReqwestClient`] that may also reach the local network, for development and
277    /// tests; see [Local development and tests](ReqwestClient#local-development-and-tests).
278    ///
279    /// # Examples
280    ///
281    /// ```
282    /// use identity_kit::methods::web::ReqwestClient;
283    ///
284    /// let client = ReqwestClient::builder().dangerous_allow_loopback().build()?;
285    /// assert!(client.local_network().loopback && !client.local_network().private);
286    /// # Ok::<(), identity_kit::methods::web::HttpError>(())
287    /// ```
288    #[derive(Debug, Clone)]
289    #[must_use]
290    pub struct ReqwestClientBuilder {
291        pub(super) local: LocalNetwork,
292    }
293
294    impl ReqwestClientBuilder {
295        /// Lets the client reach `127.0.0.0/8` and `::1` over plain `http`. For development
296        /// and tests only.
297        pub const fn dangerous_allow_loopback(mut self) -> Self {
298            self.local.loopback = true;
299            self
300        }
301
302        /// Lets the client reach RFC 1918 addresses and `fc00::/7` over plain `http`. For
303        /// development and tests only.
304        pub const fn dangerous_allow_private_network(mut self) -> Self {
305            self.local.private = true;
306            self
307        }
308
309        /// The client: [`ReqwestClient::new`] plus the local ranges allowed here.
310        ///
311        /// # Errors
312        ///
313        /// [`HttpError`] when the TLS backend cannot be initialized.
314        pub fn build(self) -> Result<ReqwestClient, HttpError> {
315            let mut client = ReqwestClient::new()?;
316            client.local = self.local;
317            Ok(client)
318        }
319    }
320
321    impl ReqwestClient {
322        /// The local ranges this client may reach; [`LocalNetwork::NONE`] unless built with
323        /// [`ReqwestClient::builder`].
324        pub const fn local_network(&self) -> LocalNetwork {
325            self.local
326        }
327
328        /// Fetches `url` over plain `http` when every address of its host is in an allowed
329        /// local range; `None` when the public rules apply.
330        pub(super) async fn get_local(
331            &self,
332            url: &Url,
333            accept: Option<&str>,
334        ) -> Result<Option<HttpResponse>, HttpError> {
335            if self.local == LocalNetwork::NONE || !matches!(url.scheme(), "https" | "http") {
336                return Ok(None);
337            }
338            // An `http` or `https` URL always has a host and a port.
339            let port = url.port_or_known_default().unwrap_or(443);
340            let host = url.host().expect("an `http` or `https` URL has a host");
341            let (name, addresses): (Option<&str>, Vec<IpAddr>) = match host {
342                Host::Ipv4(ip) => (None, vec![IpAddr::V4(ip)]),
343                Host::Ipv6(ip) => (None, vec![IpAddr::V6(ip)]),
344                Host::Domain(name) => {
345                    let addresses = tokio::net::lookup_host((name, port))
346                        .await
347                        .map_err(HttpError::new)?
348                        .map(|address| address.ip())
349                        .collect();
350                    (Some(name), addresses)
351                }
352            };
353            if !self.local.all_local(&addresses) {
354                return Ok(None);
355            }
356            debug_assert!(addresses.iter().all(|ip| !is_public(*ip)));
357            let target = plain_http(url, port);
358            let mut builder = reqwest::Client::builder()
359                .no_proxy()
360                .redirect(reqwest::redirect::Policy::none())
361                .timeout(ReqwestClient::DEFAULT_TIMEOUT)
362                .user_agent(concat!("identity-kit/", env!("CARGO_PKG_VERSION")));
363            if let Some(name) = name {
364                let pinned: Vec<SocketAddr> = addresses
365                    .iter()
366                    .map(|ip| SocketAddr::new(*ip, port))
367                    .collect();
368                builder = builder.resolve_to_addrs(name, &pinned);
369            }
370            let client = builder.build().map_err(HttpError::new)?;
371            let response = request(&client, &target, accept)
372                .send()
373                .await
374                .map_err(HttpError::new)?;
375            read(response, self.max_body).await.map(Some)
376        }
377    }
378
379    /// `url` with the `http` scheme on `port`, the port the URL names or the `https` default it
380    /// implies.
381    pub(super) fn plain_http(url: &Url, port: u16) -> Url {
382        let mut target = url.clone();
383        // Scheme first: `set_port` drops a port equal to the current scheme's default, so 443
384        // set while the URL is still `https` would leave `http` on port 80.
385        target
386            .set_scheme("http")
387            .expect("an `http` or `https` URL can be `http`");
388        target
389            .set_port(Some(port))
390            .expect("a URL with a host takes a port");
391        target
392    }
393}
394
395/// A DNS resolver that keeps only public addresses.
396struct PublicAddresses;
397
398impl reqwest::dns::Resolve for PublicAddresses {
399    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
400        let host = name.as_str().to_owned();
401        Box::pin(async move {
402            let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
403                .await?
404                .filter(|address| is_public(address.ip()))
405                .collect();
406            if addresses.is_empty() {
407                return Err(format!("`{host}` resolves to no public address").into());
408            }
409            Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)
410        })
411    }
412}
413
414/// Whether `ip` is a globally routable unicast address: not loopback, private, link-local,
415/// unspecified, multicast, broadcast, documentation or otherwise reserved.
416pub(crate) fn is_public(ip: IpAddr) -> bool {
417    match ip {
418        IpAddr::V4(v4) => {
419            let [a, b, ..] = v4.octets();
420            !(v4.is_unspecified()
421                || v4.is_loopback()
422                || v4.is_private()
423                || v4.is_link_local()
424                || v4.is_broadcast()
425                || v4.is_documentation()
426                || v4.is_multicast()
427                || a == 0
428                || (a == 100 && (64..=127).contains(&b)) // shared address space, RFC 6598
429                || (a == 192 && b == 0 && v4.octets()[2] == 0) // IETF protocol assignments
430                || (a == 198 && (18..=19).contains(&b)) // benchmarking, RFC 2544
431                || a >= 240) // reserved and broadcast
432        }
433        IpAddr::V6(v6) => {
434            if let Some(v4) = v6.to_ipv4_mapped() {
435                return is_public(IpAddr::V4(v4));
436            }
437            let segments = v6.segments();
438            !(v6.is_unspecified()
439                || v6.is_loopback()
440                || v6.is_multicast()
441                || v6.is_unique_local()
442                || v6.is_unicast_link_local()
443                || segments[0] == 0x2001 && segments[1] == 0x0db8 // documentation
444                || segments[0] == 0x0064 && segments[1] == 0xff9b) // NAT64 well-known prefix
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[cfg(feature = "local-network")]
454    #[test]
455    fn plain_http_keeps_the_port() {
456        for (url, port, expected) in [
457            // No port: the `https` default, 443, which `http` must name explicitly.
458            (
459                "https://localhost/.well-known/did.json",
460                443,
461                "http://localhost:443/.well-known/did.json",
462            ),
463            (
464                "https://issuer/user/alice/did.json",
465                443,
466                "http://issuer:443/user/alice/did.json",
467            ),
468            (
469                "https://127.0.0.1/did.json",
470                443,
471                "http://127.0.0.1:443/did.json",
472            ),
473            ("https://[::1]/did.json", 443, "http://[::1]:443/did.json"),
474            // An explicit port is kept, including 80.
475            (
476                "https://localhost:8443/did.json",
477                8443,
478                "http://localhost:8443/did.json",
479            ),
480            (
481                "https://localhost:80/did.json",
482                80,
483                "http://localhost/did.json",
484            ),
485            // A plain `http` URL is unchanged.
486            (
487                "http://localhost:8080/did.json",
488                8080,
489                "http://localhost:8080/did.json",
490            ),
491            ("http://localhost/did.json", 80, "http://localhost/did.json"),
492        ] {
493            let url = url::Url::parse(url).unwrap();
494            let rewritten = local::plain_http(&url, port);
495            assert_eq!(rewritten.as_str(), expected, "{url}");
496            assert_eq!(rewritten.port_or_known_default(), Some(port), "{url}");
497        }
498    }
499
500    #[test]
501    fn public_addresses() {
502        for public in [
503            "93.184.216.34",
504            "2606:2800:220:1:248:1893:25c8:1946",
505            "1.1.1.1",
506            // Near misses: each matches only one half of a reserved range's condition.
507            "8.64.0.1",
508            "100.128.0.1",
509            "8.0.0.8",
510            "8.8.0.8",
511            "192.0.1.1",
512            "8.18.0.1",
513            "2001:4860:4860::8888",
514            "2600:db8::1",
515            "64:1::1",
516            "2600:ff9b::1",
517        ] {
518            assert!(is_public(public.parse().unwrap()), "{public}");
519        }
520        for private in [
521            "127.0.0.1",
522            "127.1.2.3",
523            "10.0.0.1",
524            "172.16.0.1",
525            "172.31.255.255",
526            "192.168.1.1",
527            "169.254.169.254",
528            "0.0.0.0",
529            "100.64.0.1",
530            "192.0.0.8",
531            "198.18.0.1",
532            "198.51.100.1",
533            "224.0.0.1",
534            "255.255.255.255",
535            "::1",
536            "::",
537            "fc00::1",
538            "fd12::1",
539            "fe80::1",
540            "ff02::1",
541            "2001:db8::1",
542            "64:ff9b::7f00:1",
543            "::ffff:127.0.0.1",
544            "::ffff:10.0.0.1",
545        ] {
546            assert!(!is_public(private.parse().unwrap()), "{private}");
547        }
548    }
549}