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