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/// - stops reading a body over [`DEFAULT_MAX_BODY`](Self::DEFAULT_MAX_BODY) bytes;
21/// - times out after [`DEFAULT_TIMEOUT`](Self::DEFAULT_TIMEOUT).
22///
23/// [`ReqwestClient::from_client`] wraps a client the application configured itself, keeping
24/// only the HTTPS-only rule, the refusal of non-public IP literals and the body limit; the
25/// other protections are then the application's.
26#[derive(Debug, Clone)]
27pub struct ReqwestClient {
28    client: reqwest::Client,
29    max_body: usize,
30}
31
32impl ReqwestClient {
33    /// The largest body [`new`](Self::new) reads: 1 MiB, far above any DID document.
34    pub const DEFAULT_MAX_BODY: usize = 1 << 20;
35
36    /// The request timeout of [`new`](Self::new).
37    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
38
39    /// A hardened client with the defaults above.
40    ///
41    /// # Errors
42    ///
43    /// [`HttpError`] when the TLS backend cannot be initialized.
44    pub fn new() -> Result<Self, HttpError> {
45        // Make `ring` the process default when nothing chose a provider yet; a provider another
46        // part of the application installed is kept.
47        let _ = rustls::crypto::ring::default_provider().install_default();
48        let client = reqwest::Client::builder()
49            .https_only(true)
50            .redirect(reqwest::redirect::Policy::none())
51            .dns_resolver(Arc::new(PublicAddresses))
52            .timeout(Self::DEFAULT_TIMEOUT)
53            .user_agent(concat!("identity-kit/", env!("CARGO_PKG_VERSION")))
54            .build()
55            .map_err(HttpError::new)?;
56        Ok(Self {
57            client,
58            max_body: Self::DEFAULT_MAX_BODY,
59        })
60    }
61
62    /// A client over `client` as the application configured it, still HTTPS-only, refusing
63    /// non-public IP literals, and reading at most [`DEFAULT_MAX_BODY`](Self::DEFAULT_MAX_BODY)
64    /// bytes of body.
65    pub const fn from_client(client: reqwest::Client) -> Self {
66        Self {
67            client,
68            max_body: Self::DEFAULT_MAX_BODY,
69        }
70    }
71
72    /// This client reading at most `max_body` bytes of body.
73    #[must_use]
74    pub const fn with_max_body(mut self, max_body: usize) -> Self {
75        self.max_body = max_body;
76        self
77    }
78
79    /// The body limit.
80    pub const fn max_body(&self) -> usize {
81        self.max_body
82    }
83
84    /// The underlying client.
85    pub const fn client(&self) -> &reqwest::Client {
86        &self.client
87    }
88}
89
90#[async_trait::async_trait]
91impl HttpClient for ReqwestClient {
92    async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
93        if url.scheme() != "https" {
94            return Err(HttpError::msg(format!("`{url}` is not an HTTPS URL")));
95        }
96        // An IP literal never reaches the DNS resolver, so it is checked here.
97        let literal = match url.host() {
98            Some(Host::Ipv4(ip)) => Some(IpAddr::V4(ip)),
99            Some(Host::Ipv6(ip)) => Some(IpAddr::V6(ip)),
100            _ => None,
101        };
102        if literal.is_some_and(|ip| !is_public(ip)) {
103            return Err(HttpError::msg(format!("`{url}` is not a public address")));
104        }
105        let mut response = self
106            .client
107            .get(url.as_str())
108            .send()
109            .await
110            .map_err(HttpError::new)?;
111        let status = response.status().as_u16();
112        if response
113            .content_length()
114            .is_some_and(|len| len > self.max_body as u64)
115        {
116            return Err(HttpError::msg(format!(
117                "response body exceeds {} bytes",
118                self.max_body
119            )));
120        }
121        let mut body = Vec::new();
122        while let Some(chunk) = response.chunk().await.map_err(HttpError::new)? {
123            if body.len() + chunk.len() > self.max_body {
124                return Err(HttpError::msg(format!(
125                    "response body exceeds {} bytes",
126                    self.max_body
127                )));
128            }
129            body.extend_from_slice(&chunk);
130        }
131        Ok(HttpResponse::new(status, body))
132    }
133}
134
135/// A DNS resolver that keeps only public addresses.
136struct PublicAddresses;
137
138impl reqwest::dns::Resolve for PublicAddresses {
139    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
140        let host = name.as_str().to_owned();
141        Box::pin(async move {
142            let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
143                .await?
144                .filter(|address| is_public(address.ip()))
145                .collect();
146            if addresses.is_empty() {
147                return Err(format!("`{host}` resolves to no public address").into());
148            }
149            Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)
150        })
151    }
152}
153
154/// Whether `ip` is a globally routable unicast address: not loopback, private, link-local,
155/// unspecified, multicast, broadcast, documentation or otherwise reserved.
156pub(crate) fn is_public(ip: IpAddr) -> bool {
157    match ip {
158        IpAddr::V4(v4) => {
159            let [a, b, ..] = v4.octets();
160            !(v4.is_unspecified()
161                || v4.is_loopback()
162                || v4.is_private()
163                || v4.is_link_local()
164                || v4.is_broadcast()
165                || v4.is_documentation()
166                || v4.is_multicast()
167                || a == 0
168                || (a == 100 && (64..=127).contains(&b)) // shared address space, RFC 6598
169                || (a == 192 && b == 0 && v4.octets()[2] == 0) // IETF protocol assignments
170                || (a == 198 && (18..=19).contains(&b)) // benchmarking, RFC 2544
171                || a >= 240) // reserved and broadcast
172        }
173        IpAddr::V6(v6) => {
174            if let Some(v4) = v6.to_ipv4_mapped() {
175                return is_public(IpAddr::V4(v4));
176            }
177            let segments = v6.segments();
178            !(v6.is_unspecified()
179                || v6.is_loopback()
180                || v6.is_multicast()
181                || v6.is_unique_local()
182                || v6.is_unicast_link_local()
183                || segments[0] == 0x2001 && segments[1] == 0x0db8 // documentation
184                || segments[0] == 0x0064 && segments[1] == 0xff9b) // NAT64 well-known prefix
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn public_addresses() {
195        for public in [
196            "93.184.216.34",
197            "2606:2800:220:1:248:1893:25c8:1946",
198            "1.1.1.1",
199        ] {
200            assert!(is_public(public.parse().unwrap()), "{public}");
201        }
202        for private in [
203            "127.0.0.1",
204            "127.1.2.3",
205            "10.0.0.1",
206            "172.16.0.1",
207            "172.31.255.255",
208            "192.168.1.1",
209            "169.254.169.254",
210            "0.0.0.0",
211            "100.64.0.1",
212            "192.0.0.8",
213            "198.18.0.1",
214            "198.51.100.1",
215            "224.0.0.1",
216            "255.255.255.255",
217            "::1",
218            "::",
219            "fc00::1",
220            "fd12::1",
221            "fe80::1",
222            "ff02::1",
223            "2001:db8::1",
224            "64:ff9b::7f00:1",
225            "::ffff:127.0.0.1",
226            "::ffff:10.0.0.1",
227        ] {
228            assert!(!is_public(private.parse().unwrap()), "{private}");
229        }
230    }
231}