1use 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#[derive(thiserror::Error, Debug)]
26pub enum DnsResolutionError {
27 #[error(
29 "Address resolved to a private IP. The provided host is not routable on the public internet"
30 )]
31 PrivateAddress,
32 #[error("Address did not resolve to any IPs")]
34 NoAddressesFound,
35 #[error(transparent)]
37 Io(#[from] io::Error),
38}
39
40pub async fn resolve_address(
43 mut host: &str,
44 enforce_global: bool,
45) -> Result<BTreeSet<IpAddr>, DnsResolutionError> {
46 let mut port = 0;
47 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
72pub 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
93static V4_NON_GLOBAL: LazyLock<Vec<Ipv4Net>> = LazyLock::new(|| {
98 [
99 "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", ]
114 .iter()
115 .map(|s| s.parse().expect("valid CIDR"))
116 .collect()
117});
118
119static V6_NON_GLOBAL: LazyLock<Vec<Ipv6Net>> = LazyLock::new(|| {
123 [
124 "::/128", "::1/128", "fc00::/7", "fe80::/10", ]
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 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 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 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}