Skip to main content

mz_ore/netio/
dns.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16use std::collections::BTreeSet;
17use std::io;
18use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
19use std::sync::LazyLock;
20
21use ipnet::{Ipv4Net, Ipv6Net};
22use tokio::net::lookup_host;
23
24/// An error returned by `resolve_address`.
25#[derive(thiserror::Error, Debug)]
26pub enum DnsResolutionError {
27    /// private ip
28    #[error(
29        "Address resolved to a private IP. The provided host is not routable on the public internet"
30    )]
31    PrivateAddress,
32    /// no addresses
33    #[error("Address did not resolve to any IPs")]
34    NoAddressesFound,
35    /// io error
36    #[error(transparent)]
37    Io(#[from] io::Error),
38}
39
40/// Resolves a host address and ensures it is a global address when `enforce_global` is set.
41/// This parameter is useful when connecting to user-defined unverified addresses.
42pub async fn resolve_address(
43    mut host: &str,
44    enforce_global: bool,
45) -> Result<BTreeSet<IpAddr>, DnsResolutionError> {
46    let mut port = 0;
47    // If a port is already specified, use it and remove it from the host.
48    if let Some(idx) = host.find(':') {
49        if let Ok(p) = host[idx + 1..].parse() {
50            port = p;
51            host = &host[..idx];
52        }
53    }
54
55    let mut addrs = lookup_host((host, port)).await?;
56    let mut ips = BTreeSet::new();
57    while let Some(addr) = addrs.next() {
58        let ip = addr.ip();
59        if enforce_global && !is_global(ip) {
60            Err(DnsResolutionError::PrivateAddress)?
61        } else {
62            ips.insert(ip);
63        }
64    }
65
66    if ips.len() == 0 {
67        Err(DnsResolutionError::NoAddressesFound)?
68    }
69    Ok(ips)
70}
71
72/// If `url`'s host is an IP literal, validates that it is a globally routable
73/// address, returning [`DnsResolutionError::PrivateAddress`] if not. Hostnames
74/// and URLs without a host are passed through; they are expected to be
75/// validated by a connector-level DNS resolver at connect time.
76///
77/// This closes the gap that reqwest/hyper custom DNS resolvers are only
78/// invoked for hostnames, so IP-literal URLs (e.g. `http://127.0.0.1`) would
79/// otherwise bypass global-address enforcement.
80pub fn ensure_url_ip_global(url: &url::Url) -> Result<(), DnsResolutionError> {
81    let ip = match url.host() {
82        Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
83        Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
84        Some(url::Host::Domain(_)) | None => return Ok(()),
85    };
86    if is_global(ip) {
87        Ok(())
88    } else {
89        Err(DnsResolutionError::PrivateAddress)
90    }
91}
92
93/// IPv4 CIDR blocks that are not globally routable. Anything outside this set
94/// is treated as a public address.
95// TODO: Switch to `Ipv4Addr::is_global()` once stable:
96// https://github.com/rust-lang/rust/issues/27709
97static V4_NON_GLOBAL: LazyLock<Vec<Ipv4Net>> = LazyLock::new(|| {
98    [
99        "0.0.0.0/8",       // unspecified / "this network"
100        "10.0.0.0/8",      // private (RFC 1918)
101        "100.64.0.0/10",   // shared address space / CGNAT (RFC 6598)
102        "127.0.0.0/8",     // loopback
103        "169.254.0.0/16",  // link-local
104        "172.16.0.0/12",   // private (RFC 1918)
105        "192.0.0.0/24",    // IETF protocol assignments
106        "192.0.2.0/24",    // documentation (TEST-NET-1)
107        "192.168.0.0/16",  // private (RFC 1918)
108        "198.18.0.0/15",   // benchmarking
109        "198.51.100.0/24", // documentation (TEST-NET-2)
110        "203.0.113.0/24",  // documentation (TEST-NET-3)
111        "224.0.0.0/4",     // multicast
112        "240.0.0.0/4",     // reserved (includes broadcast 255.255.255.255)
113    ]
114    .iter()
115    .map(|s| s.parse().expect("valid CIDR"))
116    .collect()
117});
118
119/// IPv6 CIDR blocks that are not globally routable.
120// TODO: Switch to `Ipv6Addr::is_global()` once stable:
121// https://github.com/rust-lang/rust/issues/27709
122static V6_NON_GLOBAL: LazyLock<Vec<Ipv6Net>> = LazyLock::new(|| {
123    [
124        "::/128",    // unspecified
125        "::1/128",   // loopback
126        "fc00::/7",  // unique local
127        "fe80::/10", // link-local
128    ]
129    .iter()
130    .map(|s| s.parse().expect("valid CIDR"))
131    .collect()
132});
133
134fn is_global(addr: IpAddr) -> bool {
135    match addr {
136        IpAddr::V4(ip) => is_global_v4(ip),
137        IpAddr::V6(ip) => is_global_v6(ip),
138    }
139}
140
141fn is_global_v4(ip: Ipv4Addr) -> bool {
142    !V4_NON_GLOBAL.iter().any(|net| net.contains(&ip))
143}
144
145fn is_global_v6(ip: Ipv6Addr) -> bool {
146    // Treat IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) as their underlying
147    // IPv4 address — connecting to `::ffff:127.0.0.1` reaches loopback on
148    // dual-stack sockets.
149    if let Some(v4) = ip.to_ipv4_mapped() {
150        return is_global_v4(v4);
151    }
152    !V6_NON_GLOBAL.iter().any(|net| net.contains(&ip))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    // Use IP literals so the tests don't depend on /etc/hosts or DNS.
160    const PRIVATE_V4: &str = "127.0.0.1";
161    const PUBLIC_V4: &str = "8.8.8.8";
162    const LOOPBACK_V6: &str = "::1";
163
164    #[crate::test(tokio::test)]
165    #[cfg_attr(miri, ignore)]
166    async fn resolve_address_rejects_loopback_v4_when_enforced() {
167        let err = resolve_address(PRIVATE_V4, true)
168            .await
169            .expect_err("loopback should be rejected");
170        assert!(
171            matches!(err, DnsResolutionError::PrivateAddress),
172            "got {err:?}"
173        );
174    }
175
176    #[crate::test(tokio::test)]
177    #[cfg_attr(miri, ignore)]
178    async fn resolve_address_rejects_loopback_v6_when_enforced() {
179        let err = resolve_address(LOOPBACK_V6, true)
180            .await
181            .expect_err("::1 should be rejected");
182        assert!(
183            matches!(err, DnsResolutionError::PrivateAddress),
184            "got {err:?}"
185        );
186    }
187
188    #[crate::test(tokio::test)]
189    #[cfg_attr(miri, ignore)]
190    async fn resolve_address_allows_public_v4_when_enforced() {
191        let ips = resolve_address(PUBLIC_V4, true)
192            .await
193            .expect("public IP should resolve");
194        assert!(ips.contains(&PUBLIC_V4.parse::<IpAddr>().unwrap()));
195    }
196
197    #[crate::test(tokio::test)]
198    #[cfg_attr(miri, ignore)]
199    async fn resolve_address_allows_loopback_when_not_enforced() {
200        let ips = resolve_address(PRIVATE_V4, false)
201            .await
202            .expect("loopback should resolve when enforcement is off");
203        assert!(ips.contains(&PRIVATE_V4.parse::<IpAddr>().unwrap()));
204    }
205
206    #[crate::test(tokio::test)]
207    #[cfg_attr(miri, ignore)]
208    async fn resolve_address_rejects_ipv4_mapped_loopback() {
209        let err = resolve_address("::ffff:127.0.0.1", true)
210            .await
211            .expect_err("IPv4-mapped loopback must be rejected");
212        assert!(
213            matches!(err, DnsResolutionError::PrivateAddress),
214            "got {err:?}"
215        );
216    }
217
218    #[crate::test(tokio::test)]
219    #[cfg_attr(miri, ignore)]
220    async fn resolve_address_rejects_ipv6_unique_local() {
221        let err = resolve_address("fc00::1", true)
222            .await
223            .expect_err("ULA must be rejected");
224        assert!(
225            matches!(err, DnsResolutionError::PrivateAddress),
226            "got {err:?}"
227        );
228    }
229
230    #[crate::test(tokio::test)]
231    #[cfg_attr(miri, ignore)]
232    async fn resolve_address_rejects_ipv6_link_local() {
233        let err = resolve_address("fe80::1", true)
234            .await
235            .expect_err("link-local must be rejected");
236        assert!(
237            matches!(err, DnsResolutionError::PrivateAddress),
238            "got {err:?}"
239        );
240    }
241
242    #[crate::test(tokio::test)]
243    #[cfg_attr(miri, ignore)]
244    async fn resolve_address_rejects_ipv4_cgnat() {
245        // 100.64.0.0/10 is the IETF shared-address-space range used for
246        // carrier-grade NAT — `Ipv4Addr::is_global` rejects it.
247        let err = resolve_address("100.64.0.1", true)
248            .await
249            .expect_err("CGNAT must be rejected");
250        assert!(
251            matches!(err, DnsResolutionError::PrivateAddress),
252            "got {err:?}"
253        );
254    }
255
256    #[crate::test(tokio::test)]
257    #[cfg_attr(miri, ignore)]
258    async fn resolve_address_strips_port() {
259        let ips = resolve_address(&format!("{PUBLIC_V4}:443"), true)
260            .await
261            .expect("host:port form should parse");
262        assert!(ips.contains(&PUBLIC_V4.parse::<IpAddr>().unwrap()));
263    }
264}