Skip to main content

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}