1use std::net::IpAddr;
11
12use url::Url;
13
14use crate::domain_match::domain_matches;
15use crate::executor::ToolError;
16
17pub use zeph_common::net::is_private_ip;
19
20pub fn validate_url(raw: &str) -> Result<Url, ToolError> {
33 let parsed = Url::parse(raw).map_err(|_| ToolError::Blocked {
34 command: format!("invalid URL: {raw}"),
35 })?;
36
37 if parsed.scheme() != "https" {
38 return Err(ToolError::Blocked {
39 command: format!("scheme not allowed: {}", parsed.scheme()),
40 });
41 }
42
43 if let Some(host) = parsed.host()
44 && is_private_host(&host)
45 {
46 return Err(ToolError::Blocked {
47 command: format!(
48 "private/local host blocked: {}",
49 parsed.host_str().unwrap_or("")
50 ),
51 });
52 }
53
54 Ok(parsed)
55}
56
57#[must_use]
60pub fn is_private_host(host: &url::Host<&str>) -> bool {
61 match host {
62 url::Host::Domain(d) => {
63 #[allow(clippy::case_sensitive_file_extension_comparisons)]
66 {
67 *d == "localhost"
68 || d.ends_with(".localhost")
69 || d.ends_with(".internal")
70 || d.ends_with(".local")
71 }
72 }
73 url::Host::Ipv4(v4) => is_private_ip(IpAddr::V4(*v4)),
74 url::Host::Ipv6(v6) => is_private_ip(IpAddr::V6(*v6)),
75 }
76}
77
78pub fn check_domain_policy(
95 host: &str,
96 allowed_domains: &[String],
97 denied_domains: &[String],
98) -> Result<(), ToolError> {
99 if denied_domains.iter().any(|p| domain_matches(p, host)) {
100 return Err(ToolError::Blocked {
101 command: format!("domain blocked by denylist: {host}"),
102 });
103 }
104 if !allowed_domains.is_empty() {
105 let is_ip =
107 host.parse::<IpAddr>().is_ok() || (host.starts_with('[') && host.ends_with(']'));
108 if is_ip {
109 return Err(ToolError::Blocked {
110 command: format!(
111 "bare IP address not allowed when domain allowlist is active: {host}"
112 ),
113 });
114 }
115 if !allowed_domains.iter().any(|p| domain_matches(p, host)) {
116 return Err(ToolError::Blocked {
117 command: format!("domain not in allowlist: {host}"),
118 });
119 }
120 }
121 Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use std::assert_matches;
128 use std::net::{Ipv4Addr, Ipv6Addr};
129
130 #[test]
131 fn loopback_v4() {
132 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)));
133 }
134
135 #[test]
136 fn private_class_a() {
137 assert!(is_private_ip("10.0.0.1".parse().unwrap()));
138 }
139
140 #[test]
141 fn private_class_b() {
142 assert!(is_private_ip("172.16.0.1".parse().unwrap()));
143 }
144
145 #[test]
146 fn private_class_c() {
147 assert!(is_private_ip("192.168.1.1".parse().unwrap()));
148 }
149
150 #[test]
151 fn link_local_v4() {
152 assert!(is_private_ip("169.254.1.1".parse().unwrap()));
153 }
154
155 #[test]
156 fn unspecified_v4() {
157 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
158 }
159
160 #[test]
161 fn broadcast_v4() {
162 assert!(is_private_ip("255.255.255.255".parse().unwrap()));
163 }
164
165 #[test]
166 fn cgnat_v4() {
167 assert!(is_private_ip("100.64.0.1".parse().unwrap()));
168 assert!(is_private_ip("100.127.255.255".parse().unwrap()));
169 }
170
171 #[test]
172 fn public_v4_not_blocked() {
173 assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
174 assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
175 }
176
177 #[test]
178 fn loopback_v6() {
179 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
180 }
181
182 #[test]
183 fn unspecified_v6() {
184 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
185 }
186
187 #[test]
188 fn ula_v6() {
189 assert!(is_private_ip("fc00::1".parse().unwrap()));
190 assert!(is_private_ip("fd12:3456:789a::1".parse().unwrap()));
191 }
192
193 #[test]
194 fn link_local_v6() {
195 assert!(is_private_ip("fe80::1".parse().unwrap()));
196 }
197
198 #[test]
199 fn ipv4_mapped_private() {
200 assert!(is_private_ip("::ffff:127.0.0.1".parse().unwrap()));
201 assert!(is_private_ip("::ffff:192.168.0.1".parse().unwrap()));
202 assert!(is_private_ip("::ffff:100.64.0.1".parse().unwrap()));
203 }
204
205 #[test]
206 fn public_v6_not_blocked() {
207 assert!(!is_private_ip("2001:4860:4860::8888".parse().unwrap()));
208 }
209
210 #[test]
211 fn cgnat_boundary_not_blocked() {
212 assert!(!is_private_ip("100.128.0.0".parse().unwrap()));
213 }
214
215 #[test]
216 fn ipv4_mapped_unspecified() {
217 assert!(is_private_ip("::ffff:0.0.0.0".parse().unwrap()));
218 }
219
220 #[test]
223 fn valid_https_url() {
224 assert!(validate_url("https://example.com").is_ok());
225 }
226
227 #[test]
228 fn http_rejected() {
229 let err = validate_url("http://example.com").unwrap_err();
230 assert_matches!(err, ToolError::Blocked { .. });
231 }
232
233 #[test]
234 fn invalid_url_rejected() {
235 let err = validate_url("not a url").unwrap_err();
236 assert_matches!(err, ToolError::Blocked { .. });
237 }
238
239 #[test]
240 fn localhost_blocked() {
241 let err = validate_url("https://localhost/path").unwrap_err();
242 assert_matches!(err, ToolError::Blocked { .. });
243 }
244
245 #[test]
246 fn loopback_ip_blocked() {
247 let err = validate_url("https://127.0.0.1/path").unwrap_err();
248 assert_matches!(err, ToolError::Blocked { .. });
249 }
250
251 #[test]
252 fn private_10_blocked() {
253 let err = validate_url("https://10.0.0.1/api").unwrap_err();
254 assert_matches!(err, ToolError::Blocked { .. });
255 }
256
257 #[test]
258 fn public_ip_allowed() {
259 assert!(validate_url("https://93.184.216.34/page").is_ok());
260 }
261
262 #[test]
263 fn ftp_rejected() {
264 let err = validate_url("ftp://files.example.com").unwrap_err();
265 assert_matches!(err, ToolError::Blocked { .. });
266 }
267
268 #[test]
269 fn file_rejected() {
270 let err = validate_url("file:///etc/passwd").unwrap_err();
271 assert_matches!(err, ToolError::Blocked { .. });
272 }
273
274 #[test]
275 fn private_172_blocked() {
276 let err = validate_url("https://172.16.0.1/api").unwrap_err();
277 assert_matches!(err, ToolError::Blocked { .. });
278 }
279
280 #[test]
281 fn private_192_blocked() {
282 let err = validate_url("https://192.168.1.1/api").unwrap_err();
283 assert_matches!(err, ToolError::Blocked { .. });
284 }
285
286 #[test]
287 fn ipv6_loopback_blocked() {
288 let err = validate_url("https://[::1]/path").unwrap_err();
289 assert_matches!(err, ToolError::Blocked { .. });
290 }
291
292 #[test]
293 fn url_with_port_allowed() {
294 assert!(validate_url("https://example.com:8443/path").is_ok());
295 }
296
297 #[test]
298 fn link_local_ip_blocked() {
299 let err = validate_url("https://169.254.1.1/path").unwrap_err();
300 assert_matches!(err, ToolError::Blocked { .. });
301 }
302
303 #[test]
304 fn url_no_scheme_rejected() {
305 let err = validate_url("example.com/path").unwrap_err();
306 assert_matches!(err, ToolError::Blocked { .. });
307 }
308
309 #[test]
310 fn unspecified_ipv4_blocked() {
311 let err = validate_url("https://0.0.0.0/path").unwrap_err();
312 assert_matches!(err, ToolError::Blocked { .. });
313 }
314
315 #[test]
316 fn broadcast_ipv4_blocked() {
317 let err = validate_url("https://255.255.255.255/path").unwrap_err();
318 assert_matches!(err, ToolError::Blocked { .. });
319 }
320
321 #[test]
322 fn ipv6_link_local_blocked() {
323 let err = validate_url("https://[fe80::1]/path").unwrap_err();
324 assert_matches!(err, ToolError::Blocked { .. });
325 }
326
327 #[test]
328 fn ipv6_unique_local_blocked() {
329 let err = validate_url("https://[fd12::1]/path").unwrap_err();
330 assert_matches!(err, ToolError::Blocked { .. });
331 }
332
333 #[test]
334 fn ipv4_mapped_ipv6_loopback_blocked() {
335 let err = validate_url("https://[::ffff:127.0.0.1]/path").unwrap_err();
336 assert_matches!(err, ToolError::Blocked { .. });
337 }
338
339 #[test]
340 fn ipv4_mapped_ipv6_private_blocked() {
341 let err = validate_url("https://[::ffff:10.0.0.1]/path").unwrap_err();
342 assert_matches!(err, ToolError::Blocked { .. });
343 }
344
345 #[test]
348 fn denylist_blocks_regardless_of_allowlist() {
349 let err = check_domain_policy("evil.com", &[], &["evil.com".to_owned()]).unwrap_err();
350 assert_matches!(err, ToolError::Blocked { .. });
351 }
352
353 #[test]
354 fn allowlist_empty_allows_any_host() {
355 assert!(check_domain_policy("example.com", &[], &[]).is_ok());
356 }
357
358 #[test]
359 fn allowlist_rejects_non_matching_host() {
360 let err = check_domain_policy("other.com", &["example.com".to_owned()], &[]).unwrap_err();
361 assert_matches!(err, ToolError::Blocked { .. });
362 }
363
364 #[test]
365 fn allowlist_accepts_matching_host() {
366 assert!(check_domain_policy("example.com", &["example.com".to_owned()], &[]).is_ok());
367 }
368
369 #[test]
370 fn allowlist_rejects_bare_ip() {
371 let err =
372 check_domain_policy("93.184.216.34", &["example.com".to_owned()], &[]).unwrap_err();
373 assert_matches!(err, ToolError::Blocked { .. });
374 }
375}