Skip to main content

servo_fetch/
net.rs

1//! Network address validation — blocks private, reserved, and special-purpose IPs.
2
3/// Network access policy — determines which hosts are reachable.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct NetworkPolicy {
6    deny_private: bool,
7}
8
9impl NetworkPolicy {
10    /// Block all private/reserved addresses (production default).
11    pub const STRICT: Self = Self { deny_private: true };
12    /// Allow all addresses including private (testing only).
13    pub const PERMISSIVE: Self = Self { deny_private: false };
14
15    /// Check whether a host is allowed by this policy.
16    #[must_use]
17    pub fn is_host_allowed(self, host: &str) -> bool {
18        !self.deny_private || !is_private_host(host)
19    }
20}
21
22/// Validate a URL against the given [`NetworkPolicy`].
23pub(crate) fn validate_url(input: &str, policy: NetworkPolicy) -> Result<url::Url, UrlError> {
24    let mut parsed = url::Url::parse(input).map_err(|e| UrlError::Invalid(format!("invalid URL: {input}: {e}")))?;
25    match parsed.scheme() {
26        "http" | "https" => {}
27        s => {
28            return Err(UrlError::Invalid(format!(
29                "scheme '{s}' not allowed; only http:// and https:// are supported"
30            )));
31        }
32    }
33    if !parsed.username().is_empty() || parsed.password().is_some() {
34        tracing::warn!("credentials stripped from URL");
35        let _ = parsed.set_username("");
36        let _ = parsed.set_password(None);
37    }
38    if let Some(host) = parsed.host_str() {
39        if !policy.is_host_allowed(host) {
40            return Err(UrlError::PrivateAddress(host.to_string()));
41        }
42    }
43    Ok(parsed)
44}
45
46fn is_private_host(host: &str) -> bool {
47    const BLOCKED_HOSTS: &[&str] = &[
48        "localhost",
49        "127.0.0.1",
50        "[::1]",
51        "0.0.0.0",
52        "169.254.169.254",
53        "metadata.google.internal",
54    ];
55    let host = host.strip_suffix('.').unwrap_or(host);
56    if BLOCKED_HOSTS.iter().any(|&b| host.eq_ignore_ascii_case(b)) {
57        return true;
58    }
59    if let Ok(ip) = host.parse::<std::net::Ipv4Addr>() {
60        return is_private_ipv4(ip);
61    }
62    if let Ok(ip) = host
63        .trim_matches(|c| c == '[' || c == ']')
64        .parse::<std::net::Ipv6Addr>()
65    {
66        return is_private_ipv6(&ip);
67    }
68    false
69}
70
71/// IANA IPv4 Special-Purpose Address Registry (RFC 6890) + cloud metadata.
72fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
73    const BLOCKED: &[(u32, u32)] = &[
74        (0x0000_0000, 0xff00_0000), // 0.0.0.0/8        — RFC 1122 current network
75        (0x0a00_0000, 0xff00_0000), // 10.0.0.0/8       — RFC 1918 private
76        (0x6440_0000, 0xffc0_0000), // 100.64.0.0/10    — RFC 6598 shared/CGN
77        (0x7f00_0000, 0xff00_0000), // 127.0.0.0/8      — RFC 1122 loopback
78        (0xa9fe_0000, 0xffff_0000), // 169.254.0.0/16   — RFC 3927 link-local
79        (0xac10_0000, 0xfff0_0000), // 172.16.0.0/12    — RFC 1918 private
80        (0xc000_0000, 0xffff_ff00), // 192.0.0.0/24     — RFC 6890 IETF protocol
81        (0xc000_0200, 0xffff_ff00), // 192.0.2.0/24     — RFC 5737 TEST-NET-1
82        (0xc058_6300, 0xffff_ff00), // 192.88.99.0/24   — RFC 3068 6to4 relay
83        (0xc0a8_0000, 0xffff_0000), // 192.168.0.0/16   — RFC 1918 private
84        (0xc612_0000, 0xfffe_0000), // 198.18.0.0/15    — RFC 2544 benchmarking
85        (0xc633_6400, 0xffff_ff00), // 198.51.100.0/24  — RFC 5737 TEST-NET-2
86        (0xcb00_7100, 0xffff_ff00), // 203.0.113.0/24   — RFC 5737 TEST-NET-3
87        (0xe000_0000, 0xf000_0000), // 224.0.0.0/4      — RFC 5771 multicast
88        (0xf000_0000, 0xf000_0000), // 240.0.0.0/4      — RFC 1112 reserved
89        (0xffff_ffff, 0xffff_ffff), // 255.255.255.255  — broadcast
90    ];
91    let bits = u32::from(ip);
92    BLOCKED.iter().any(|&(net, mask)| bits & mask == net)
93}
94
95/// IANA IPv6 Special-Purpose Address Registry + IPv4-mapped/compatible.
96fn is_private_ipv6(ip: &std::net::Ipv6Addr) -> bool {
97    if let Some(v4) = ip.to_ipv4_mapped().or_else(|| ip.to_ipv4()) {
98        return is_private_ipv4(v4);
99    }
100    let seg = ip.segments();
101    let s0 = seg[0];
102    ip.is_loopback()
103        || ip.is_unspecified()
104        || (s0 == 0x0100 && seg[1] == 0 && seg[2] == 0 && seg[3] == 0) // 0100::/64 discard (RFC 6666)
105        || (s0 == 0x2001 && seg[1] == 0)                               // 2001::/32 Teredo
106        || (s0 == 0x2001 && seg[1] & 0xfff0 == 0x0010)                 // 2001:10::/28 ORCHID
107        || (s0 == 0x2001 && seg[1] & 0xfff0 == 0x0020)                 // 2001:20::/28 ORCHIDv2
108        || (s0 == 0x2001 && seg[1] == 0x0db8)                          // 2001:db8::/32 documentation
109        || s0 == 0x2002                                                // 2002::/16 6to4
110        || s0 & 0xfe00 == 0xfc00                                       // fc00::/7  unique local
111        || s0 & 0xffc0 == 0xfe80                                       // fe80::/10 link-local
112        || s0 & 0xff00 == 0xff00 // ff00::/8  multicast
113}
114
115/// Why a URL was rejected by [`validate_url`].
116#[derive(Debug)]
117pub(crate) enum UrlError {
118    /// Malformed URL or disallowed scheme.
119    Invalid(String),
120    /// Host resolves to a private or reserved address.
121    PrivateAddress(String),
122}
123
124impl std::fmt::Display for UrlError {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Self::Invalid(reason) => f.write_str(reason),
128            Self::PrivateAddress(host) => {
129                write!(f, "access to private/local addresses is not allowed: {host}")
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn blocks_localhost() {
141        assert!(is_private_host("localhost"));
142        assert!(is_private_host("127.0.0.1"));
143        assert!(is_private_host("[::1]"));
144    }
145
146    #[test]
147    fn blocks_private_ipv4() {
148        assert!(is_private_host("10.0.0.1"));
149        assert!(is_private_host("192.168.1.1"));
150        assert!(is_private_host("172.16.0.1"));
151    }
152
153    #[test]
154    fn blocks_shared_cgn() {
155        assert!(is_private_host("100.64.0.1"));
156        assert!(is_private_host("100.127.255.254"));
157    }
158
159    #[test]
160    fn blocks_documentation_ips() {
161        assert!(is_private_host("192.0.2.1"));
162        assert!(is_private_host("198.51.100.1"));
163        assert!(is_private_host("203.0.113.1"));
164    }
165
166    #[test]
167    fn blocks_multicast() {
168        assert!(is_private_host("224.0.0.1"));
169    }
170
171    #[test]
172    fn blocks_metadata() {
173        assert!(is_private_host("169.254.169.254"));
174        assert!(is_private_host("metadata.google.internal"));
175    }
176
177    #[test]
178    fn blocks_ipv4_mapped_ipv6() {
179        assert!(is_private_host("::ffff:127.0.0.1"));
180        assert!(is_private_host("::ffff:10.0.0.1"));
181    }
182
183    #[test]
184    fn blocks_ipv6_special() {
185        assert!(is_private_host("fe80::1"));
186        assert!(is_private_host("fd00::1"));
187        assert!(is_private_host("2001:db8::1"));
188    }
189
190    #[test]
191    fn allows_public() {
192        assert!(!is_private_host("8.8.8.8"));
193        assert!(!is_private_host("1.1.1.1"));
194        assert!(!is_private_host("example.com"));
195    }
196
197    #[test]
198    fn blocks_zero_address() {
199        assert!(is_private_host("0.0.0.0"));
200    }
201
202    #[test]
203    fn blocks_localhost_case_insensitive() {
204        assert!(is_private_host("LOCALHOST"));
205        assert!(is_private_host("Localhost"));
206    }
207
208    #[test]
209    fn blocks_broadcast() {
210        assert!(is_private_host("255.255.255.255"));
211    }
212
213    #[test]
214    fn blocks_reserved_240() {
215        assert!(is_private_host("240.0.0.1"));
216    }
217
218    #[test]
219    fn blocks_teredo() {
220        assert!(is_private_host("2001::1"));
221    }
222
223    #[test]
224    fn blocks_6to4() {
225        assert!(is_private_host("2002::1"));
226    }
227
228    #[test]
229    fn validate_url_empty_string() {
230        assert!(validate_url("", NetworkPolicy::STRICT).is_err());
231    }
232
233    #[test]
234    fn blocks_trailing_dot() {
235        assert!(is_private_host("localhost."));
236        assert!(is_private_host("metadata.google.internal."));
237    }
238}