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}