didbot_http/address.rs
1//! The one list of address blocks this workspace will not fetch from, and
2//! the resolution that enforces it.
3//!
4//! Every outbound GET aimed by something this process did not write -- a
5//! `client_id` a stranger posted, an OIDC issuer a token names, the service
6//! endpoint inside a DID document -- is a request this server makes into
7//! whatever network it is deployed in, aimed by somebody else. Resolving
8//! the host first and refusing the whole set unless every address is public
9//! is what keeps that from reaching this deployment's own network.
10
11use std::net::{IpAddr, SocketAddr};
12use std::sync::LazyLock;
13
14use url::{Host, Url};
15
16/// The address blocks an outbound fetch is refused for.
17///
18/// `169.254.169.254` is the one worth naming: on EC2 it answers with the
19/// instance's own IAM credentials, and `didbot_dns::route53` reads it for
20/// exactly that.
21///
22/// Written as prefixes rather than as `std::net`'s predicates because the
23/// list is then the whole statement, readable against IANA's own
24/// registries, and because the two predicates that would cover the IPv6
25/// half (`is_unique_local`, `is_unicast_link_local`) are not stable.
26pub const REFUSED_BLOCKS: &[&str] = &[
27 // IPv4.
28 "0.0.0.0/8", // "this host on this network"
29 "10.0.0.0/8", // private
30 "100.64.0.0/10", // carrier-grade NAT
31 "127.0.0.0/8", // loopback
32 "169.254.0.0/16", // link-local, and the instance metadata service
33 "172.16.0.0/12", // private
34 "192.0.0.0/24", // IETF protocol assignments
35 "192.0.2.0/24", // documentation
36 "192.168.0.0/16", // private
37 "198.18.0.0/15", // benchmarking
38 "198.51.100.0/24", // documentation
39 "203.0.113.0/24", // documentation
40 "224.0.0.0/4", // multicast
41 "240.0.0.0/4", // reserved
42 "255.255.255.255/32", // broadcast
43 // IPv6.
44 "::/128", // unspecified
45 "::1/128", // loopback
46 "64:ff9b::/96", // NAT64
47 "100::/64", // discard-only
48 "2001:db8::/32", // documentation
49 "fc00::/7", // unique local
50 "fe80::/10", // link-local
51 "ff00::/8", // multicast
52];
53
54/// [`REFUSED_BLOCKS`], parsed once.
55static REFUSED: LazyLock<Vec<ipnet::IpNet>> = LazyLock::new(|| {
56 REFUSED_BLOCKS
57 .iter()
58 .map(|block| block.parse().expect("a constant `ipnet` accepts"))
59 .collect()
60});
61
62/// Whether an outbound fetch may reach `address`: true unless it falls in
63/// [`REFUSED_BLOCKS`].
64///
65/// An IPv4-mapped IPv6 address is judged as the IPv4 address it carries, so
66/// `::ffff:169.254.169.254` is not a way past the list.
67pub fn is_public_address(address: IpAddr) -> bool {
68 let address = address.to_canonical();
69 !REFUSED.iter().any(|block| block.contains(&address))
70}
71
72/// Whether `url` names this machine and nowhere else.
73///
74/// `localhost` by name, or any address the standard library calls a
75/// loopback address, which covers `127.0.0.0/8` and `::1` without this
76/// having to enumerate either.
77///
78/// The host is taken from [`Url::host`] rather than [`Url::host_str`]. The
79/// string form of an IPv6 host keeps the brackets the URL syntax requires,
80/// `[::1]` parses as no address at all, and a check written against it
81/// refuses a client listening on IPv6 loopback.
82pub fn is_loopback(url: &Url) -> bool {
83 match url.host() {
84 Some(Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
85 Some(Host::Ipv4(address)) => address.is_loopback(),
86 Some(Host::Ipv6(address)) => address.is_loopback(),
87 None => false,
88 }
89}
90
91/// Whether a caller accepts a host that resolves into [`REFUSED_BLOCKS`].
92///
93/// [`PrivateAddresses::Allowed`] is a development deployment's posture --
94/// a loopback zone, a loopback operator, a test stack -- and never a
95/// deployment on the public internet.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum PrivateAddresses {
98 /// Refuse the fetch. Every deployment on the public internet.
99 Refused,
100 /// Fetch anyway.
101 Allowed,
102}
103
104/// Why an address check did not let a fetch proceed.
105#[derive(Debug, thiserror::Error)]
106pub enum AddressError {
107 /// Not a URL, or a URL with no host in it to resolve.
108 #[error("{url:?} names no host to resolve")]
109 NoHost {
110 /// The URL as it was given.
111 url: String,
112 },
113 /// The host did not resolve, or resolved to no address at all.
114 #[error("resolving {host}: {message}")]
115 Unresolvable {
116 /// The host that did not resolve.
117 host: String,
118 /// What the resolver said.
119 message: String,
120 },
121 /// The host resolves into [`REFUSED_BLOCKS`] -- this deployment's own
122 /// network rather than the public internet.
123 #[error("{host} resolves to an address this server will not fetch from")]
124 Refused {
125 /// The host that resolved there.
126 host: String,
127 },
128 /// The pinned client could not be built.
129 #[error("building a client for {host}: {message}")]
130 NoClient {
131 /// The host the client was for.
132 host: String,
133 /// What `reqwest` said.
134 message: String,
135 },
136}
137
138/// Every address `url`'s host answers to, refused unless every one of them
139/// is public.
140///
141/// Every address, not the first: a name that answers with one public
142/// address and one loopback address is a name aimed at this deployment's
143/// own network, whichever one `reqwest` goes on to pick.
144///
145/// A host that resolves to nothing at all is refused here rather than left
146/// to fail in the fetch, so that both outcomes cost the same and a caller
147/// can map them to one answer.
148///
149/// The addresses come back so a caller can pin its client to them -- see
150/// [`pinned_client`], which is the only way a check made here still holds
151/// at connect time.
152pub async fn resolve_public(
153 url: &Url,
154 private: PrivateAddresses,
155) -> Result<Vec<SocketAddr>, AddressError> {
156 let port = url.port_or_known_default().unwrap_or(443);
157 let (host, addresses): (String, Vec<SocketAddr>) = match url.host() {
158 Some(Host::Ipv4(address)) => (
159 address.to_string(),
160 vec![SocketAddr::new(address.into(), port)],
161 ),
162 Some(Host::Ipv6(address)) => (
163 address.to_string(),
164 vec![SocketAddr::new(address.into(), port)],
165 ),
166 Some(Host::Domain(name)) => {
167 let resolved = crate::lookup_host(name, port).await.map_err(|error| {
168 AddressError::Unresolvable {
169 host: name.to_owned(),
170 message: error.to_string(),
171 }
172 })?;
173 (name.to_owned(), resolved)
174 }
175 None => {
176 return Err(AddressError::NoHost {
177 url: url.to_string(),
178 })
179 }
180 };
181 if addresses.is_empty() {
182 return Err(AddressError::Unresolvable {
183 host,
184 message: "the name resolves to nothing".to_owned(),
185 });
186 }
187 if private == PrivateAddresses::Refused
188 && !addresses
189 .iter()
190 .all(|address| is_public_address(address.ip()))
191 {
192 return Err(AddressError::Refused { host });
193 }
194 Ok(addresses)
195}
196
197/// [`resolve_public`] for a caller holding its URL as text, when nothing
198/// past the refusal is wanted.
199pub async fn refuse_private_host(url: &str, private: PrivateAddresses) -> Result<(), AddressError> {
200 let parsed = Url::parse(url).map_err(|_| AddressError::NoHost {
201 url: url.to_owned(),
202 })?;
203 resolve_public(&parsed, private).await.map(|_| ())
204}
205
206/// A [`client`](crate::client) for `url`: its host resolved once through
207/// [`resolve_public`], pinned to what it resolved to so the connection
208/// cannot land elsewhere, and following no redirect, which would land it
209/// elsewhere all the same.
210///
211/// A URL whose host is already a literal address has nothing to pin --
212/// there is no name, so nothing can answer differently the second time.
213pub async fn pinned_client(
214 url: &Url,
215 private: PrivateAddresses,
216) -> Result<reqwest::Client, AddressError> {
217 let addresses = resolve_public(url, private).await?;
218 let mut builder = crate::builder().redirect(reqwest::redirect::Policy::none());
219 if let Some(Host::Domain(name)) = url.host() {
220 builder = builder.resolve_to_addrs(name, &addresses);
221 }
222 builder.build().map_err(|error| AddressError::NoClient {
223 host: url.host_str().unwrap_or_default().to_owned(),
224 message: error.to_string(),
225 })
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn this_deployments_own_network_is_not_public() {
234 for address in [
235 "169.254.169.254",
236 "127.0.0.1",
237 "10.1.2.3",
238 "172.16.0.1",
239 "192.168.1.1",
240 "100.64.0.1",
241 "100.100.100.200",
242 "0.0.0.0",
243 "255.255.255.255",
244 "224.0.0.1",
245 "::1",
246 "::",
247 "fd00::1",
248 "fe80::1",
249 "ff02::1",
250 // The IPv4-mapped spelling of the first entry: judged as the
251 // IPv4 address it carries, not as an unlisted IPv6 one.
252 "::ffff:169.254.169.254",
253 "::ffff:10.0.0.1",
254 // The blocks a predicate-based check leaves out.
255 "192.0.0.1",
256 "192.0.2.1",
257 "198.18.0.1",
258 "198.51.100.1",
259 "203.0.113.1",
260 "240.0.0.1",
261 "64:ff9b::1",
262 "100::1",
263 "2001:db8::1",
264 ] {
265 assert!(
266 !is_public_address(address.parse().expect("a literal address")),
267 "{address} is fetchable"
268 );
269 }
270 for address in [
271 "8.8.8.8",
272 "93.184.216.34",
273 "2606:4700::1111",
274 "2606:2800:220:1:248:1893:25c8:1946",
275 ] {
276 assert!(
277 is_public_address(address.parse().expect("a literal address")),
278 "{address} is not fetchable"
279 );
280 }
281 }
282
283 fn loopback(url: &str) -> bool {
284 is_loopback(&Url::parse(url).expect("a URL"))
285 }
286
287 #[test]
288 fn this_machine_by_name_or_by_number() {
289 assert!(loopback("http://localhost:40831/cb"));
290 assert!(loopback("http://LOCALHOST:40831/cb"));
291 assert!(loopback("http://127.0.0.1:40831/cb"));
292 // The whole 127/8 block, not just the one address everybody types.
293 assert!(loopback("http://127.9.9.9/cb"));
294 assert!(loopback("http://[::1]:40831/cb"));
295 }
296
297 #[test]
298 fn anywhere_else_is_not_loopback() {
299 assert!(!loopback("https://example.invalid/cb"));
300 // A host that merely reads like this machine is somebody else's.
301 assert!(!loopback("http://localhost.example.invalid/cb"));
302 assert!(!loopback("http://10.0.0.1/cb"));
303 }
304
305 #[tokio::test]
306 async fn a_literal_private_host_is_refused_without_resolving_anything() {
307 let url = Url::parse("https://169.254.169.254/latest/meta-data/").expect("a URL");
308 let error = resolve_public(&url, PrivateAddresses::Refused)
309 .await
310 .expect_err("the metadata service is refused");
311 assert!(matches!(error, AddressError::Refused { .. }), "{error:?}");
312 }
313
314 #[tokio::test]
315 async fn a_development_deployment_may_reach_loopback() {
316 let url = Url::parse("http://127.0.0.1:8080/xrpc/x").expect("a URL");
317 let addresses = resolve_public(&url, PrivateAddresses::Allowed)
318 .await
319 .expect("allowed");
320 assert_eq!(addresses, vec![SocketAddr::from(([127, 0, 0, 1], 8080))]);
321 }
322
323 #[tokio::test]
324 async fn text_that_is_not_a_url_has_no_host() {
325 let error = refuse_private_host("not a url", PrivateAddresses::Refused)
326 .await
327 .expect_err("nothing to resolve");
328 assert!(matches!(error, AddressError::NoHost { .. }), "{error:?}");
329 }
330}