Skip to main content

swink_agent_plugin_web/
domain.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, 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
31impl DomainFilter {
32    /// Check whether the given URL is permitted by the filter.
33    ///
34    /// Steps:
35    /// 1. Scheme must be `http` or `https`.
36    /// 2. Host must be extractable.
37    /// 3. If the allowlist is non-empty the host must appear in it.
38    /// 4. The host must not appear in the denylist.
39    /// 5. If `block_private_ips` is enabled, DNS-resolved addresses are checked
40    ///    against private/loopback/link-local ranges (SSRF protection).
41    pub fn is_allowed(&self, url: &Url) -> Result<(), DomainFilterError> {
42        // 1. Scheme check.
43        let scheme = url.scheme();
44        if scheme != "http" && scheme != "https" {
45            return Err(DomainFilterError::InvalidScheme(scheme.to_string()));
46        }
47
48        // 2. Extract host.
49        let host = url
50            .host_str()
51            .ok_or_else(|| DomainFilterError::InvalidUrl("URL has no host".to_string()))?;
52
53        // 3. Allowlist check.
54        if !self.allowlist.is_empty()
55            && !self.allowlist.iter().any(|a| a.eq_ignore_ascii_case(host))
56        {
57            return Err(DomainFilterError::NotAllowlisted(host.to_string()));
58        }
59
60        // 4. Denylist check.
61        if self.denylist.iter().any(|d| d.eq_ignore_ascii_case(host)) {
62            return Err(DomainFilterError::DeniedDomain(host.to_string()));
63        }
64
65        // 5. Private IP / SSRF check.
66        if self.block_private_ips {
67            let addrs = format!("{host}:80")
68                .to_socket_addrs()
69                .map_err(|e| DomainFilterError::DnsError(host.to_string(), e.to_string()))?;
70
71            for addr in addrs {
72                if is_private_ip(&addr.ip()) {
73                    return Err(DomainFilterError::PrivateIp(addr.ip().to_string()));
74                }
75            }
76        }
77
78        Ok(())
79    }
80}
81
82/// Returns `true` if the IP address belongs to a private, loopback,
83/// link-local, or otherwise non-routable range.
84fn is_private_ip(ip: &IpAddr) -> bool {
85    match ip {
86        IpAddr::V4(v4) => is_private_ipv4(v4),
87        IpAddr::V6(v6) => is_private_ipv6(v6),
88    }
89}
90
91fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
92    let octets = ip.octets();
93    // 0.0.0.0
94    if *ip == Ipv4Addr::UNSPECIFIED {
95        return true;
96    }
97    // 127.0.0.0/8 (loopback)
98    if octets[0] == 127 {
99        return true;
100    }
101    // 10.0.0.0/8
102    if octets[0] == 10 {
103        return true;
104    }
105    // 172.16.0.0/12
106    if octets[0] == 172 && (16..=31).contains(&octets[1]) {
107        return true;
108    }
109    // 192.168.0.0/16
110    if octets[0] == 192 && octets[1] == 168 {
111        return true;
112    }
113    // 169.254.0.0/16 (link-local)
114    if octets[0] == 169 && octets[1] == 254 {
115        return true;
116    }
117    false
118}
119
120fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
121    // ::1 (loopback)
122    if ip.is_loopback() {
123        return true;
124    }
125    // fc00::/7 (unique local addresses)
126    let segments = ip.segments();
127    if segments[0] & 0xfe00 == 0xfc00 {
128        return true;
129    }
130    false
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{DomainFilter, DomainFilterError};
136    use url::Url;
137
138    #[test]
139    fn rejects_invalid_schemes() {
140        let filter = DomainFilter::default();
141        let file = Url::parse("file:///etc/passwd").unwrap();
142        let ftp = Url::parse("ftp://example.com/pub").unwrap();
143
144        assert!(matches!(
145            filter.is_allowed(&file).unwrap_err(),
146            DomainFilterError::InvalidScheme(_)
147        ));
148        assert!(matches!(
149            filter.is_allowed(&ftp).unwrap_err(),
150            DomainFilterError::InvalidScheme(_)
151        ));
152    }
153
154    #[test]
155    fn allowlist_and_denylist_are_enforced() {
156        let allow_filter = DomainFilter {
157            allowlist: vec!["example.com".to_string()],
158            ..Default::default()
159        };
160        let deny_filter = DomainFilter {
161            denylist: vec!["evil.com".to_string()],
162            ..Default::default()
163        };
164
165        assert!(
166            allow_filter
167                .is_allowed(&Url::parse("https://example.com/page").unwrap())
168                .is_ok()
169        );
170        assert!(matches!(
171            allow_filter
172                .is_allowed(&Url::parse("https://evil.com").unwrap())
173                .unwrap_err(),
174            DomainFilterError::NotAllowlisted(_)
175        ));
176        assert!(matches!(
177            deny_filter
178                .is_allowed(&Url::parse("https://evil.com/malware").unwrap())
179                .unwrap_err(),
180            DomainFilterError::DeniedDomain(_)
181        ));
182    }
183
184    #[test]
185    fn private_ip_ranges_are_blocked() {
186        let filter = DomainFilter {
187            block_private_ips: true,
188            ..Default::default()
189        };
190
191        for url in [
192            "http://127.0.0.1/admin",
193            "http://10.0.0.1/internal",
194            "http://172.16.0.1/secret",
195            "http://192.168.1.1/router",
196        ] {
197            assert!(filter.is_allowed(&Url::parse(url).unwrap()).is_err());
198        }
199    }
200}