Skip to main content

zeph_common/
net.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Network utilities shared across crates.
5
6use std::net::{IpAddr, SocketAddr};
7use std::time::Duration;
8
9/// Timeout applied to the DNS lookup performed by [`resolve_and_validate`].
10const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
11
12/// Returns `true` if `addr` is a non-routable or private IP address that
13/// should be blocked for outbound connections (SSRF defense).
14///
15/// Covers:
16/// - IPv4: loopback (`127/8`), private (`10/8`, `172.16/12`, `192.168/16`),
17///   link-local (`169.254/16`), unspecified (`0.0.0.0`), broadcast (`255.255.255.255`),
18///   CGNAT (`100.64.0.0/10`, RFC 6598).
19/// - IPv6: loopback (`::1`), unspecified (`::`), ULA (`fc00::/7`),
20///   link-local (`fe80::/10`), IPv4-mapped (`::ffff:x.x.x.x` — applies IPv4 rules).
21#[must_use]
22pub fn is_private_ip(addr: IpAddr) -> bool {
23    match addr {
24        IpAddr::V4(ip) => {
25            let n = u32::from(ip);
26            ip.is_loopback()
27                || ip.is_private()
28                || ip.is_link_local()
29                || ip.is_unspecified()
30                || ip.is_broadcast()
31                // CGNAT range 100.64.0.0/10 (RFC 6598).
32                || (n & 0xFFC0_0000 == 0x6440_0000)
33        }
34        IpAddr::V6(ip) => {
35            ip.is_loopback()
36                || ip.is_unspecified()
37                || ip.to_ipv4_mapped().is_some_and(|v4| {
38                    let n = u32::from(v4);
39                    v4.is_loopback()
40                        || v4.is_private()
41                        || v4.is_link_local()
42                        || v4.is_unspecified()
43                        || v4.is_broadcast()
44                        || (n & 0xFFC0_0000 == 0x6440_0000)
45                })
46                || (ip.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
47                || (ip.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
48        }
49    }
50}
51
52/// Returns `true` if `host` is a loopback target: an IP literal in the loopback range
53/// (`127.0.0.0/8`, `::1`) or the well-known hostname `localhost` (case-insensitive).
54///
55/// Accepts IPv6 literals with or without the bracket notation used in URL authorities
56/// (`::1` and `[::1]` both match), since callers typically extract `host` from a parsed
57/// [`url::Url`] — `Url::host_str()` retains the brackets, but `Url::host()` does not.
58///
59/// This is a syntactic check only — it does not perform DNS resolution, so it cannot
60/// be spoofed by a malicious DNS response and carries no SSRF risk of its own. Callers
61/// use it to grant loopback targets a narrow trust carve-out (e.g. allowing plain HTTP
62/// to a local daemon) without weakening SSRF protection for any other hostname, which
63/// still goes through [`resolve_and_validate`].
64///
65/// # Examples
66///
67/// ```rust
68/// use zeph_common::net::is_loopback_host;
69///
70/// assert!(is_loopback_host("127.0.0.1"));
71/// assert!(is_loopback_host("::1"));
72/// assert!(is_loopback_host("[::1]"));
73/// assert!(is_loopback_host("localhost"));
74/// assert!(is_loopback_host("LOCALHOST"));
75/// assert!(!is_loopback_host("example.com"));
76/// assert!(!is_loopback_host("10.0.0.1"));
77/// ```
78#[must_use]
79pub fn is_loopback_host(host: &str) -> bool {
80    if host.eq_ignore_ascii_case("localhost") {
81        return true;
82    }
83    let unbracketed = host.strip_prefix('[').and_then(|h| h.strip_suffix(']'));
84    unbracketed
85        .unwrap_or(host)
86        .parse::<IpAddr>()
87        .is_ok_and(|ip| ip.is_loopback())
88}
89
90/// Error returned by [`resolve_and_validate`] when a hostname cannot be safely resolved.
91///
92/// Callers map this into their own error type — it carries enough context (the timeout,
93/// the underlying I/O error, or the offending address) to build a user-facing message.
94#[derive(Debug, thiserror::Error)]
95#[non_exhaustive]
96pub enum ResolveError {
97    /// DNS resolution did not complete within the lookup timeout.
98    #[error("DNS resolution timed out after {0:?}")]
99    Timeout(Duration),
100    /// The DNS lookup itself failed (NXDOMAIN, network error, etc.).
101    #[error("DNS resolution failed: {0}")]
102    Lookup(std::io::Error),
103    /// A resolved address falls in a private/loopback/link-local range.
104    #[error("SSRF protection: private IP {addr} for host {host}")]
105    PrivateAddress {
106        /// The hostname that was being resolved.
107        host: String,
108        /// The rejected private/loopback address.
109        addr: IpAddr,
110    },
111}
112
113/// Resolves `host:port` via DNS and rejects the result if any resolved address is
114/// private, loopback, link-local, or otherwise non-routable per [`is_private_ip`].
115///
116/// Returns the full set of resolved [`SocketAddr`]s on success so the caller can pin
117/// an HTTP client to them (e.g. via `reqwest::ClientBuilder::resolve_to_addrs`),
118/// eliminating the TOCTOU window between this check and the actual connection —
119/// resolving again at connect time could return a different (attacker-controlled)
120/// address for the same hostname (DNS rebinding).
121///
122/// # Errors
123///
124/// Returns [`ResolveError::Timeout`] if the lookup exceeds 10 seconds,
125/// [`ResolveError::Lookup`] if DNS resolution fails, or
126/// [`ResolveError::PrivateAddress`] if any resolved address is private/loopback.
127///
128/// # Examples
129///
130/// ```rust
131/// # async fn example() {
132/// use zeph_common::net::resolve_and_validate;
133///
134/// // A private hostname is rejected before any connection is attempted.
135/// let result = resolve_and_validate("localhost", 443).await;
136/// assert!(result.is_err());
137/// # }
138/// ```
139pub async fn resolve_and_validate(host: &str, port: u16) -> Result<Vec<SocketAddr>, ResolveError> {
140    let addrs: Vec<SocketAddr> =
141        tokio::time::timeout(RESOLVE_TIMEOUT, tokio::net::lookup_host((host, port)))
142            .await
143            .map_err(|_| ResolveError::Timeout(RESOLVE_TIMEOUT))?
144            .map_err(ResolveError::Lookup)?
145            .collect();
146
147    for addr in &addrs {
148        if is_private_ip(addr.ip()) {
149            return Err(ResolveError::PrivateAddress {
150                host: host.to_owned(),
151                addr: addr.ip(),
152            });
153        }
154    }
155
156    Ok(addrs)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::net::{Ipv4Addr, Ipv6Addr};
163
164    #[test]
165    fn loopback_is_private() {
166        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)));
167        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
168    }
169
170    #[test]
171    fn private_ranges() {
172        assert!(is_private_ip("10.0.0.1".parse().unwrap()));
173        assert!(is_private_ip("172.16.0.1".parse().unwrap()));
174        assert!(is_private_ip("192.168.1.1".parse().unwrap()));
175    }
176
177    #[test]
178    fn link_local() {
179        assert!(is_private_ip("169.254.0.1".parse().unwrap()));
180    }
181
182    #[test]
183    fn unspecified() {
184        assert!(is_private_ip("0.0.0.0".parse().unwrap()));
185        assert!(is_private_ip("::".parse().unwrap()));
186    }
187
188    #[test]
189    fn broadcast() {
190        assert!(is_private_ip("255.255.255.255".parse().unwrap()));
191    }
192
193    #[test]
194    fn cgnat() {
195        assert!(is_private_ip("100.64.0.1".parse().unwrap()));
196        assert!(is_private_ip("100.127.255.255".parse().unwrap()));
197        assert!(!is_private_ip("100.128.0.1".parse().unwrap()));
198    }
199
200    #[test]
201    fn public_ipv4() {
202        assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
203        assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
204        assert!(!is_private_ip("93.184.216.34".parse().unwrap()));
205    }
206
207    #[test]
208    fn ipv6_unique_local() {
209        assert!(is_private_ip("fc00::1".parse().unwrap()));
210        assert!(is_private_ip("fd00::1".parse().unwrap()));
211    }
212
213    #[test]
214    fn ipv6_link_local() {
215        assert!(is_private_ip("fe80::1".parse().unwrap()));
216    }
217
218    #[test]
219    fn ipv6_public() {
220        assert!(!is_private_ip("2001:4860:4860::8888".parse().unwrap()));
221    }
222
223    #[tokio::test]
224    async fn resolve_and_validate_rejects_loopback_hostname() {
225        let err = resolve_and_validate("localhost", 443).await.unwrap_err();
226        assert!(matches!(err, ResolveError::PrivateAddress { .. }));
227        assert!(err.to_string().contains("SSRF protection"));
228    }
229
230    #[tokio::test]
231    async fn resolve_and_validate_rejects_loopback_ip_literal() {
232        let err = resolve_and_validate("127.0.0.1", 443).await.unwrap_err();
233        assert!(matches!(err, ResolveError::PrivateAddress { .. }));
234    }
235
236    #[test]
237    fn is_loopback_host_matches_ip_literals_and_localhost() {
238        assert!(is_loopback_host("127.0.0.1"));
239        assert!(is_loopback_host("127.0.0.2"));
240        assert!(is_loopback_host("::1"));
241        assert!(is_loopback_host("[::1]"));
242        assert!(is_loopback_host("localhost"));
243        assert!(is_loopback_host("LOCALHOST"));
244        assert!(is_loopback_host("LocalHost"));
245    }
246
247    #[test]
248    fn is_loopback_host_rejects_non_loopback() {
249        assert!(!is_loopback_host("example.com"));
250        assert!(!is_loopback_host("10.0.0.1"));
251        assert!(!is_loopback_host("192.168.1.1"));
252        assert!(!is_loopback_host("8.8.8.8"));
253        assert!(!is_loopback_host(""));
254    }
255
256    #[test]
257    fn is_loopback_host_covers_full_127_8_range() {
258        // `is_loopback_host` delegates to `IpAddr::is_loopback`, which covers the whole
259        // 127.0.0.0/8 range, not just the literal 127.0.0.1 — verify the top of that range.
260        assert!(is_loopback_host("127.255.255.255"));
261    }
262
263    #[test]
264    fn is_loopback_host_does_not_match_ipv4_mapped_ipv6_loopback() {
265        // `Ipv6Addr::is_loopback` only recognizes the literal `::1` — it does not unwrap
266        // IPv4-mapped addresses the way `is_private_ip` does (which explicitly calls
267        // `to_ipv4_mapped()`). So `::ffff:127.0.0.1` is NOT detected as loopback here.
268        // This is a known, safe-direction gap: such a host falls through to the hardened
269        // `client_cfg` policy in `resolve_client_security_policy` (fails closed, not open),
270        // so it is not a security bug — just an accepted false negative for a URL form
271        // that a caller is unlikely to type by hand.
272        assert!(!is_loopback_host("::ffff:127.0.0.1"));
273        assert!(!is_loopback_host("[::ffff:127.0.0.1]"));
274    }
275
276    #[test]
277    fn is_loopback_host_rejects_unspecified_address() {
278        // `0.0.0.0` is unspecified, not loopback — it must never get the loopback
279        // carve-out, or a locally-bound wildcard listener could be reached over plain HTTP.
280        assert!(!is_loopback_host("0.0.0.0"));
281    }
282}