1use std::sync::Once;
4
5use crate::error::{UrlError, map_url_error};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct NetworkPolicy {
10 deny_private: bool,
11}
12
13impl NetworkPolicy {
14 pub const STRICT: Self = Self { deny_private: true };
16 pub const PERMISSIVE: Self = Self { deny_private: false };
18
19 #[must_use]
21 pub fn is_host_allowed(self, host: &str) -> bool {
22 !self.deny_private || !is_private_host(host)
23 }
24}
25
26pub fn validate_url(url: &str) -> crate::error::Result<url::Url> {
29 validate_url_with_policy(url, crate::bridge::engine_policy()).map_err(|e| map_url_error(url, e))
30}
31
32pub(crate) fn validate_url_with_policy(input: &str, policy: NetworkPolicy) -> Result<url::Url, UrlError> {
34 let mut parsed = url::Url::parse(input).map_err(|e| UrlError::Invalid(format!("invalid URL: {input}: {e}")))?;
35 match parsed.scheme() {
36 "http" | "https" => {}
37 s => {
38 return Err(UrlError::Invalid(format!(
39 "scheme '{s}' not allowed; only http:// and https:// are supported"
40 )));
41 }
42 }
43 if !parsed.username().is_empty() || parsed.password().is_some() {
44 tracing::warn!("credentials stripped from URL");
45 let _ = parsed.set_username("");
46 let _ = parsed.set_password(None);
47 }
48 if let Some(host) = parsed.host_str() {
49 if !policy.is_host_allowed(host) {
50 return Err(UrlError::PrivateAddress(host.to_string()));
51 }
52 }
53 Ok(parsed)
54}
55
56pub(crate) fn sanitize_user_agent(ua: String) -> String {
58 if ua.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
59 ua.replace(['\r', '\n', '\0'], " ")
60 } else {
61 ua
62 }
63}
64
65pub(crate) fn ensure_crypto_provider() {
66 static ONCE: Once = Once::new();
67 ONCE.call_once(|| {
68 if rustls::crypto::CryptoProvider::get_default().is_none() {
69 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
70 }
71 });
72}
73
74fn is_private_host(host: &str) -> bool {
75 const BLOCKED_HOSTS: &[&str] = &[
76 "localhost",
77 "127.0.0.1",
78 "[::1]",
79 "0.0.0.0",
80 "169.254.169.254",
81 "metadata.google.internal",
82 ];
83 let host = host.strip_suffix('.').unwrap_or(host);
84 if BLOCKED_HOSTS.iter().any(|&b| host.eq_ignore_ascii_case(b)) {
85 return true;
86 }
87 if let Ok(ip) = host.parse::<std::net::Ipv4Addr>() {
88 return is_private_ipv4(ip);
89 }
90 if let Ok(ip) = host
91 .trim_matches(|c| c == '[' || c == ']')
92 .parse::<std::net::Ipv6Addr>()
93 {
94 return is_private_ipv6(&ip);
95 }
96 false
97}
98
99fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
101 const BLOCKED: &[(u32, u32)] = &[
102 (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), ];
119 let bits = u32::from(ip);
120 BLOCKED.iter().any(|&(net, mask)| bits & mask == net)
121}
122
123fn is_private_ipv6(ip: &std::net::Ipv6Addr) -> bool {
125 if let Some(v4) = ip.to_ipv4_mapped().or_else(|| ip.to_ipv4()) {
126 return is_private_ipv4(v4);
127 }
128 let seg = ip.segments();
129 let s0 = seg[0];
130 ip.is_loopback()
131 || ip.is_unspecified()
132 || (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 }
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn blocks_localhost() {
149 assert!(is_private_host("localhost"));
150 assert!(is_private_host("127.0.0.1"));
151 assert!(is_private_host("[::1]"));
152 }
153
154 #[test]
155 fn blocks_private_ipv4() {
156 assert!(is_private_host("10.0.0.1"));
157 assert!(is_private_host("192.168.1.1"));
158 assert!(is_private_host("172.16.0.1"));
159 }
160
161 #[test]
162 fn blocks_shared_cgn() {
163 assert!(is_private_host("100.64.0.1"));
164 assert!(is_private_host("100.127.255.254"));
165 }
166
167 #[test]
168 fn blocks_documentation_ips() {
169 assert!(is_private_host("192.0.2.1"));
170 assert!(is_private_host("198.51.100.1"));
171 assert!(is_private_host("203.0.113.1"));
172 }
173
174 #[test]
175 fn blocks_multicast() {
176 assert!(is_private_host("224.0.0.1"));
177 }
178
179 #[test]
180 fn blocks_metadata() {
181 assert!(is_private_host("169.254.169.254"));
182 assert!(is_private_host("metadata.google.internal"));
183 }
184
185 #[test]
186 fn blocks_ipv4_mapped_ipv6() {
187 assert!(is_private_host("::ffff:127.0.0.1"));
188 assert!(is_private_host("::ffff:10.0.0.1"));
189 }
190
191 #[test]
192 fn blocks_ipv6_special() {
193 assert!(is_private_host("fe80::1"));
194 assert!(is_private_host("fd00::1"));
195 assert!(is_private_host("2001:db8::1"));
196 }
197
198 #[test]
199 fn allows_public() {
200 assert!(!is_private_host("8.8.8.8"));
201 assert!(!is_private_host("1.1.1.1"));
202 assert!(!is_private_host("example.com"));
203 }
204
205 #[test]
206 fn blocks_zero_address() {
207 assert!(is_private_host("0.0.0.0"));
208 }
209
210 #[test]
211 fn blocks_localhost_case_insensitive() {
212 assert!(is_private_host("LOCALHOST"));
213 assert!(is_private_host("Localhost"));
214 }
215
216 #[test]
217 fn blocks_broadcast() {
218 assert!(is_private_host("255.255.255.255"));
219 }
220
221 #[test]
222 fn blocks_reserved_240() {
223 assert!(is_private_host("240.0.0.1"));
224 }
225
226 #[test]
227 fn blocks_teredo() {
228 assert!(is_private_host("2001::1"));
229 }
230
231 #[test]
232 fn blocks_6to4() {
233 assert!(is_private_host("2002::1"));
234 }
235
236 #[test]
237 fn validate_url_empty_string() {
238 assert!(validate_url_with_policy("", NetworkPolicy::STRICT).is_err());
239 }
240
241 #[test]
242 fn blocks_trailing_dot() {
243 assert!(is_private_host("localhost."));
244 assert!(is_private_host("metadata.google.internal."));
245 }
246}