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/// Error returned by [`resolve_and_validate`] when a hostname cannot be safely resolved.
53///
54/// Callers map this into their own error type — it carries enough context (the timeout,
55/// the underlying I/O error, or the offending address) to build a user-facing message.
56#[derive(Debug, thiserror::Error)]
57#[non_exhaustive]
58pub enum ResolveError {
59    /// DNS resolution did not complete within the lookup timeout.
60    #[error("DNS resolution timed out after {0:?}")]
61    Timeout(Duration),
62    /// The DNS lookup itself failed (NXDOMAIN, network error, etc.).
63    #[error("DNS resolution failed: {0}")]
64    Lookup(std::io::Error),
65    /// A resolved address falls in a private/loopback/link-local range.
66    #[error("SSRF protection: private IP {addr} for host {host}")]
67    PrivateAddress {
68        /// The hostname that was being resolved.
69        host: String,
70        /// The rejected private/loopback address.
71        addr: IpAddr,
72    },
73}
74
75/// Resolves `host:port` via DNS and rejects the result if any resolved address is
76/// private, loopback, link-local, or otherwise non-routable per [`is_private_ip`].
77///
78/// Returns the full set of resolved [`SocketAddr`]s on success so the caller can pin
79/// an HTTP client to them (e.g. via `reqwest::ClientBuilder::resolve_to_addrs`),
80/// eliminating the TOCTOU window between this check and the actual connection —
81/// resolving again at connect time could return a different (attacker-controlled)
82/// address for the same hostname (DNS rebinding).
83///
84/// # Errors
85///
86/// Returns [`ResolveError::Timeout`] if the lookup exceeds 10 seconds,
87/// [`ResolveError::Lookup`] if DNS resolution fails, or
88/// [`ResolveError::PrivateAddress`] if any resolved address is private/loopback.
89///
90/// # Examples
91///
92/// ```rust
93/// # async fn example() {
94/// use zeph_common::net::resolve_and_validate;
95///
96/// // A private hostname is rejected before any connection is attempted.
97/// let result = resolve_and_validate("localhost", 443).await;
98/// assert!(result.is_err());
99/// # }
100/// ```
101pub async fn resolve_and_validate(host: &str, port: u16) -> Result<Vec<SocketAddr>, ResolveError> {
102    let addrs: Vec<SocketAddr> =
103        tokio::time::timeout(RESOLVE_TIMEOUT, tokio::net::lookup_host((host, port)))
104            .await
105            .map_err(|_| ResolveError::Timeout(RESOLVE_TIMEOUT))?
106            .map_err(ResolveError::Lookup)?
107            .collect();
108
109    for addr in &addrs {
110        if is_private_ip(addr.ip()) {
111            return Err(ResolveError::PrivateAddress {
112                host: host.to_owned(),
113                addr: addr.ip(),
114            });
115        }
116    }
117
118    Ok(addrs)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::net::{Ipv4Addr, Ipv6Addr};
125
126    #[test]
127    fn loopback_is_private() {
128        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)));
129        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
130    }
131
132    #[test]
133    fn private_ranges() {
134        assert!(is_private_ip("10.0.0.1".parse().unwrap()));
135        assert!(is_private_ip("172.16.0.1".parse().unwrap()));
136        assert!(is_private_ip("192.168.1.1".parse().unwrap()));
137    }
138
139    #[test]
140    fn link_local() {
141        assert!(is_private_ip("169.254.0.1".parse().unwrap()));
142    }
143
144    #[test]
145    fn unspecified() {
146        assert!(is_private_ip("0.0.0.0".parse().unwrap()));
147        assert!(is_private_ip("::".parse().unwrap()));
148    }
149
150    #[test]
151    fn broadcast() {
152        assert!(is_private_ip("255.255.255.255".parse().unwrap()));
153    }
154
155    #[test]
156    fn cgnat() {
157        assert!(is_private_ip("100.64.0.1".parse().unwrap()));
158        assert!(is_private_ip("100.127.255.255".parse().unwrap()));
159        assert!(!is_private_ip("100.128.0.1".parse().unwrap()));
160    }
161
162    #[test]
163    fn public_ipv4() {
164        assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
165        assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
166        assert!(!is_private_ip("93.184.216.34".parse().unwrap()));
167    }
168
169    #[test]
170    fn ipv6_unique_local() {
171        assert!(is_private_ip("fc00::1".parse().unwrap()));
172        assert!(is_private_ip("fd00::1".parse().unwrap()));
173    }
174
175    #[test]
176    fn ipv6_link_local() {
177        assert!(is_private_ip("fe80::1".parse().unwrap()));
178    }
179
180    #[test]
181    fn ipv6_public() {
182        assert!(!is_private_ip("2001:4860:4860::8888".parse().unwrap()));
183    }
184
185    #[tokio::test]
186    async fn resolve_and_validate_rejects_loopback_hostname() {
187        let err = resolve_and_validate("localhost", 443).await.unwrap_err();
188        assert!(matches!(err, ResolveError::PrivateAddress { .. }));
189        assert!(err.to_string().contains("SSRF protection"));
190    }
191
192    #[tokio::test]
193    async fn resolve_and_validate_rejects_loopback_ip_literal() {
194        let err = resolve_and_validate("127.0.0.1", 443).await.unwrap_err();
195        assert!(matches!(err, ResolveError::PrivateAddress { .. }));
196    }
197}