Skip to main content

swink_agent_plugin_web/
domain.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
2
3use thiserror::Error;
4use url::Url;
5
6/// Errors from domain filtering.
7#[non_exhaustive]
8#[derive(Debug, Error)]
9pub enum DomainFilterError {
10    #[error("URL scheme '{0}' is not allowed; only http and https are permitted")]
11    InvalidScheme(String),
12    #[error("Domain '{0}' is on the deny list")]
13    DeniedDomain(String),
14    #[error("Domain '{0}' is not on the allow list")]
15    NotAllowlisted(String),
16    #[error("Address {0} is a private/internal IP and is blocked")]
17    PrivateIp(String),
18    #[error("Failed to parse URL: {0}")]
19    InvalidUrl(String),
20    #[error("DNS resolution failed for '{0}': {1}")]
21    DnsError(String, String),
22}
23
24/// Domain allowlist/denylist with built-in SSRF protection.
25#[non_exhaustive]
26#[derive(Debug, Clone, Default)]
27pub struct DomainFilter {
28    pub allowlist: Vec<String>,
29    pub denylist: Vec<String>,
30    pub block_private_ips: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub(crate) struct ResolvedHost {
35    pub host: String,
36    pub addr: SocketAddr,
37}
38
39impl DomainFilter {
40    /// Construct a filter that preserves open-domain access while blocking
41    /// private, loopback, link-local, and otherwise non-routable IP ranges.
42    #[must_use]
43    pub fn blocking_private_ips() -> Self {
44        Self {
45            block_private_ips: true,
46            ..Self::default()
47        }
48    }
49
50    /// Check whether the given URL is permitted by the filter.
51    ///
52    /// Steps:
53    /// 1. Scheme must be `http` or `https`.
54    /// 2. Host must be extractable.
55    /// 3. If the allowlist is non-empty the host must appear in it.
56    /// 4. The host must not appear in the denylist.
57    /// 5. If `block_private_ips` is enabled, DNS-resolved addresses are checked
58    ///    against private/loopback/link-local ranges (SSRF protection).
59    pub fn is_allowed(&self, url: &Url) -> Result<(), DomainFilterError> {
60        self.validate_and_resolve(url).map(|_| ())
61    }
62
63    pub(crate) fn validate_and_resolve(
64        &self,
65        url: &Url,
66    ) -> Result<Option<ResolvedHost>, DomainFilterError> {
67        // 1. Scheme check.
68        let scheme = url.scheme();
69        if scheme != "http" && scheme != "https" {
70            return Err(DomainFilterError::InvalidScheme(scheme.to_string()));
71        }
72
73        // 2. Extract host.
74        let host = url
75            .host_str()
76            .ok_or_else(|| DomainFilterError::InvalidUrl("URL has no host".to_string()))?;
77
78        // 3. Allowlist check.
79        if !self.allowlist.is_empty()
80            && !self.allowlist.iter().any(|a| a.eq_ignore_ascii_case(host))
81        {
82            return Err(DomainFilterError::NotAllowlisted(host.to_string()));
83        }
84
85        // 4. Denylist check.
86        if self.denylist.iter().any(|d| d.eq_ignore_ascii_case(host)) {
87            return Err(DomainFilterError::DeniedDomain(host.to_string()));
88        }
89
90        // 5. Private IP / SSRF check.
91        //
92        // Use the typed `url.host()` enum rather than `host_str()`: IPv6
93        // literals in `host_str()` keep their brackets ("[::1]"), which
94        // neither `Ipv6Addr` parsing nor getaddrinfo accepts, so IP literals
95        // are classified directly from the already-parsed address.
96        if self.block_private_ips {
97            match url.host() {
98                Some(url::Host::Ipv4(ip)) => {
99                    if is_private_ip(&IpAddr::V4(ip)) {
100                        return Err(DomainFilterError::PrivateIp(ip.to_string()));
101                    }
102                }
103                Some(url::Host::Ipv6(ip)) => {
104                    if is_private_ip(&IpAddr::V6(ip)) {
105                        return Err(DomainFilterError::PrivateIp(ip.to_string()));
106                    }
107                }
108                Some(url::Host::Domain(domain)) => {
109                    let port = url.port_or_known_default().unwrap_or(80);
110                    let mut first_public_addr = None;
111                    let addrs = (domain, port).to_socket_addrs().map_err(|e| {
112                        DomainFilterError::DnsError(domain.to_string(), e.to_string())
113                    })?;
114
115                    for addr in addrs {
116                        if is_private_ip(&addr.ip()) {
117                            return Err(DomainFilterError::PrivateIp(addr.ip().to_string()));
118                        }
119                        if first_public_addr.is_none() {
120                            first_public_addr = Some(addr);
121                        }
122                    }
123
124                    let addr = first_public_addr.ok_or_else(|| {
125                        DomainFilterError::DnsError(
126                            domain.to_string(),
127                            "no addresses found".to_string(),
128                        )
129                    })?;
130                    return Ok(Some(ResolvedHost {
131                        host: domain.to_string(),
132                        addr,
133                    }));
134                }
135                None => {
136                    return Err(DomainFilterError::InvalidUrl("URL has no host".to_string()));
137                }
138            }
139        }
140
141        Ok(None)
142    }
143}
144
145/// Returns `true` if the IP address belongs to a private, loopback,
146/// link-local, or otherwise non-routable range.
147fn is_private_ip(ip: &IpAddr) -> bool {
148    match ip {
149        IpAddr::V4(v4) => is_private_ipv4(v4),
150        IpAddr::V6(v6) => is_private_ipv6(v6),
151    }
152}
153
154fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
155    let octets = ip.octets();
156    // 0.0.0.0/8 (current network)
157    if octets[0] == 0 {
158        return true;
159    }
160    // 127.0.0.0/8 (loopback)
161    if octets[0] == 127 {
162        return true;
163    }
164    // 10.0.0.0/8
165    if octets[0] == 10 {
166        return true;
167    }
168    // 172.16.0.0/12
169    if octets[0] == 172 && (16..=31).contains(&octets[1]) {
170        return true;
171    }
172    // 192.168.0.0/16
173    if octets[0] == 192 && octets[1] == 168 {
174        return true;
175    }
176    // 169.254.0.0/16 (link-local)
177    if octets[0] == 169 && octets[1] == 254 {
178        return true;
179    }
180    // 100.64.0.0/10 (carrier-grade NAT)
181    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
182        return true;
183    }
184    // 198.18.0.0/15 (benchmarking)
185    if octets[0] == 198 && (18..=19).contains(&octets[1]) {
186        return true;
187    }
188    // Documentation/test networks.
189    if (octets[0] == 192 && octets[1] == 0 && octets[2] == 2)
190        || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100)
191        || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113)
192    {
193        return true;
194    }
195    // 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved).
196    if octets[0] >= 224 {
197        return true;
198    }
199    false
200}
201
202fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
203    if let Some(mapped) = ip.to_ipv4_mapped() {
204        return is_private_ipv4(&mapped);
205    }
206    // :: (unspecified)
207    if ip.is_unspecified() {
208        return true;
209    }
210    // ::1 (loopback)
211    if ip.is_loopback() {
212        return true;
213    }
214    let segments = ip.segments();
215    // fc00::/7 (unique local addresses)
216    if segments[0] & 0xfe00 == 0xfc00 {
217        return true;
218    }
219    // fe80::/10 (link-local unicast)
220    if segments[0] & 0xffc0 == 0xfe80 {
221        return true;
222    }
223    // ff00::/8 (multicast)
224    if segments[0] & 0xff00 == 0xff00 {
225        return true;
226    }
227    // 2001:db8::/32 (documentation)
228    if segments[0] == 0x2001 && segments[1] == 0x0db8 {
229        return true;
230    }
231    false
232}
233
234#[cfg(test)]
235mod tests {
236    use super::{DomainFilter, DomainFilterError};
237    use url::Url;
238
239    #[test]
240    fn rejects_invalid_schemes() {
241        let filter = DomainFilter::default();
242        let file = Url::parse("file:///etc/passwd").unwrap();
243        let ftp = Url::parse("ftp://example.com/pub").unwrap();
244
245        assert!(matches!(
246            filter.is_allowed(&file).unwrap_err(),
247            DomainFilterError::InvalidScheme(_)
248        ));
249        assert!(matches!(
250            filter.is_allowed(&ftp).unwrap_err(),
251            DomainFilterError::InvalidScheme(_)
252        ));
253    }
254
255    #[test]
256    fn allowlist_and_denylist_are_enforced() {
257        let allow_filter = DomainFilter {
258            allowlist: vec!["example.com".to_string()],
259            ..Default::default()
260        };
261        let deny_filter = DomainFilter {
262            denylist: vec!["evil.com".to_string()],
263            ..Default::default()
264        };
265
266        assert!(
267            allow_filter
268                .is_allowed(&Url::parse("https://example.com/page").unwrap())
269                .is_ok()
270        );
271        assert!(matches!(
272            allow_filter
273                .is_allowed(&Url::parse("https://evil.com").unwrap())
274                .unwrap_err(),
275            DomainFilterError::NotAllowlisted(_)
276        ));
277        assert!(matches!(
278            deny_filter
279                .is_allowed(&Url::parse("https://evil.com/malware").unwrap())
280                .unwrap_err(),
281            DomainFilterError::DeniedDomain(_)
282        ));
283    }
284
285    #[test]
286    fn private_ip_ranges_are_blocked() {
287        let filter = DomainFilter::blocking_private_ips();
288
289        for url in [
290            "http://0.0.0.0/admin",
291            "http://127.0.0.1/admin",
292            "http://10.0.0.1/internal",
293            "http://100.64.0.1/cgnat",
294            "http://172.16.0.1/secret",
295            "http://192.168.1.1/router",
296            "http://198.18.0.1/benchmark",
297            "http://192.0.2.1/docs",
298            "http://224.0.0.1/multicast",
299        ] {
300            assert!(filter.is_allowed(&Url::parse(url).unwrap()).is_err());
301        }
302    }
303
304    #[test]
305    fn bracketed_ipv6_private_literals_are_blocked_as_private_not_dns_error() {
306        let filter = DomainFilter::blocking_private_ips();
307
308        for url in [
309            "http://[::1]/admin",
310            "http://[::]/admin",
311            "http://[fd00::1]/internal",
312            "http://[fe80::1]/link-local",
313            "http://[2001:db8::1]/docs",
314        ] {
315            let err = filter.is_allowed(&Url::parse(url).unwrap()).unwrap_err();
316            assert!(
317                matches!(err, DomainFilterError::PrivateIp(_)),
318                "{url} should be PrivateIp, got {err:?}"
319            );
320        }
321    }
322
323    #[test]
324    fn bracketed_public_ipv6_literal_is_allowed_without_dns_resolution() {
325        let filter = DomainFilter::blocking_private_ips();
326        let url = Url::parse("http://[2606:4700:4700::1111]/").unwrap();
327
328        let resolved = filter
329            .validate_and_resolve(&url)
330            .expect("public IPv6 literal should pass the filter");
331        // IP literals are classified directly from the parsed address and
332        // need no DNS pinning.
333        assert!(resolved.is_none());
334    }
335
336    #[test]
337    fn ipv6_non_routable_ranges_are_private() {
338        for ip in [
339            "::",
340            "::1",
341            "fc00::1",
342            "fd00::1",
343            "fe80::1",
344            "ff02::1",
345            "2001:db8::1",
346            "::ffff:0.0.0.0",
347            "::ffff:10.0.0.1",
348            "::ffff:127.0.0.1",
349            "::ffff:169.254.0.1",
350            "::ffff:172.16.0.1",
351            "::ffff:192.168.1.1",
352        ] {
353            assert!(
354                super::is_private_ip(&ip.parse().unwrap()),
355                "{ip} should be blocked"
356            );
357        }
358
359        for ip in ["2606:4700:4700::1111", "::ffff:93.184.216.34"] {
360            assert!(
361                !super::is_private_ip(&ip.parse().unwrap()),
362                "{ip} should be allowed"
363            );
364        }
365    }
366}