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