Skip to main content

zeph_tools/
net.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Network utilities for tool crates.
5//!
6//! Shared SSRF and domain-policy primitives used by every outbound-network tool
7//! (`scrape.rs`, `search/`). Centralizing these here means new HTTP-based tools get the
8//! same URL-scheme, private-IP, and allow/deny-list enforcement without re-implementing it.
9
10use std::net::IpAddr;
11
12use url::Url;
13
14use crate::domain_match::domain_matches;
15use crate::executor::ToolError;
16
17// Re-export the canonical implementation from zeph-common.
18pub use zeph_common::net::is_private_ip;
19
20/// Validate that `raw` is an HTTPS URL pointing at a non-private host.
21///
22/// Rejects any scheme other than `https` and any host that resolves syntactically to a
23/// loopback/private/link-local address or one of the reserved `.localhost`/`.internal`/
24/// `.local` domain suffixes. This is a syntactic check only — callers must still run
25/// [`zeph_common::net::resolve_and_validate`] to catch DNS-level SSRF (a public hostname
26/// resolving to a private IP).
27///
28/// # Errors
29///
30/// Returns [`ToolError::Blocked`] when the URL fails to parse, uses a non-HTTPS scheme,
31/// or targets a private/local host.
32pub 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/// Returns `true` when `host` is a loopback/private-range IP literal or a reserved
58/// `.localhost`/`.internal`/`.local` domain.
59#[must_use]
60pub fn is_private_host(host: &url::Host<&str>) -> bool {
61    match host {
62        url::Host::Domain(d) => {
63            // Exact match or subdomain of localhost (e.g. foo.localhost)
64            // and .internal/.local TLDs used in cloud/k8s environments.
65            #[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
78/// Check `host` against a domain allowlist/denylist pair.
79///
80/// Logic:
81/// 1. If `denied_domains` matches the host → block.
82/// 2. If `allowed_domains` is non-empty:
83///    a. IP address hosts are always rejected (no pattern can match a bare IP).
84///    b. Hosts not matching any entry → block.
85/// 3. Otherwise → allow.
86///
87/// Wildcard prefix matching: `*.example.com` matches `sub.example.com` but NOT `example.com`.
88/// Multiple wildcards are not supported; patterns with more than one `*` are treated as exact.
89///
90/// # Errors
91///
92/// Returns [`ToolError::Blocked`] when `host` matches the denylist, or when the allowlist
93/// is non-empty and `host` is a bare IP or does not match any allowlist entry.
94pub 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        // Bare IP addresses cannot match any domain pattern — reject when allowlist is active.
106        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    // --- validate_url ---
221
222    #[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    // --- check_domain_policy ---
346
347    #[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}