1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct NetworkPolicy {
6 deny_private: bool,
7}
8
9impl NetworkPolicy {
10 pub const STRICT: Self = Self { deny_private: true };
12 pub const PERMISSIVE: Self = Self { deny_private: false };
14
15 #[must_use]
17 pub fn is_host_allowed(self, host: &str) -> bool {
18 !self.deny_private || !is_private_host(host)
19 }
20}
21
22pub(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
71fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
73 const BLOCKED: &[(u32, u32)] = &[
74 (0x0000_0000, 0xff00_0000), (0x0a00_0000, 0xff00_0000), (0x6440_0000, 0xffc0_0000), (0x7f00_0000, 0xff00_0000), (0xa9fe_0000, 0xffff_0000), (0xac10_0000, 0xfff0_0000), (0xc000_0000, 0xffff_ff00), (0xc000_0200, 0xffff_ff00), (0xc058_6300, 0xffff_ff00), (0xc0a8_0000, 0xffff_0000), (0xc612_0000, 0xfffe_0000), (0xc633_6400, 0xffff_ff00), (0xcb00_7100, 0xffff_ff00), (0xe000_0000, 0xf000_0000), (0xf000_0000, 0xf000_0000), (0xffff_ffff, 0xffff_ffff), ];
91 let bits = u32::from(ip);
92 BLOCKED.iter().any(|&(net, mask)| bits & mask == net)
93}
94
95fn 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) || (s0 == 0x2001 && seg[1] == 0) || (s0 == 0x2001 && seg[1] & 0xfff0 == 0x0010) || (s0 == 0x2001 && seg[1] & 0xfff0 == 0x0020) || (s0 == 0x2001 && seg[1] == 0x0db8) || s0 == 0x2002 || s0 & 0xfe00 == 0xfc00 || s0 & 0xffc0 == 0xfe80 || s0 & 0xff00 == 0xff00 }
114
115#[derive(Debug)]
117pub(crate) enum UrlError {
118 Invalid(String),
120 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}