swink_agent_plugin_web/
domain.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
2
3use thiserror::Error;
4use url::Url;
5
6#[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#[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 pub fn is_allowed(&self, url: &Url) -> Result<(), DomainFilterError> {
42 let scheme = url.scheme();
44 if scheme != "http" && scheme != "https" {
45 return Err(DomainFilterError::InvalidScheme(scheme.to_string()));
46 }
47
48 let host = url
50 .host_str()
51 .ok_or_else(|| DomainFilterError::InvalidUrl("URL has no host".to_string()))?;
52
53 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 if self.denylist.iter().any(|d| d.eq_ignore_ascii_case(host)) {
62 return Err(DomainFilterError::DeniedDomain(host.to_string()));
63 }
64
65 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
82fn 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 if *ip == Ipv4Addr::UNSPECIFIED {
95 return true;
96 }
97 if octets[0] == 127 {
99 return true;
100 }
101 if octets[0] == 10 {
103 return true;
104 }
105 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
107 return true;
108 }
109 if octets[0] == 192 && octets[1] == 168 {
111 return true;
112 }
113 if octets[0] == 169 && octets[1] == 254 {
115 return true;
116 }
117 false
118}
119
120fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
121 if ip.is_loopback() {
123 return true;
124 }
125 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}