swink_agent_plugin_web/
domain.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, 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
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub(crate) struct ResolvedHost {
33 pub host: String,
34 pub addr: SocketAddr,
35}
36
37impl DomainFilter {
38 #[must_use]
41 pub fn blocking_private_ips() -> Self {
42 Self {
43 block_private_ips: true,
44 ..Self::default()
45 }
46 }
47
48 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 let scheme = url.scheme();
67 if scheme != "http" && scheme != "https" {
68 return Err(DomainFilterError::InvalidScheme(scheme.to_string()));
69 }
70
71 let host = url
73 .host_str()
74 .ok_or_else(|| DomainFilterError::InvalidUrl("URL has no host".to_string()))?;
75
76 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 if self.denylist.iter().any(|d| d.eq_ignore_ascii_case(host)) {
85 return Err(DomainFilterError::DeniedDomain(host.to_string()));
86 }
87
88 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
143fn 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 if octets[0] == 0 {
156 return true;
157 }
158 if octets[0] == 127 {
160 return true;
161 }
162 if octets[0] == 10 {
164 return true;
165 }
166 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
168 return true;
169 }
170 if octets[0] == 192 && octets[1] == 168 {
172 return true;
173 }
174 if octets[0] == 169 && octets[1] == 254 {
176 return true;
177 }
178 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
180 return true;
181 }
182 if octets[0] == 198 && (18..=19).contains(&octets[1]) {
184 return true;
185 }
186 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 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 if ip.is_unspecified() {
206 return true;
207 }
208 if ip.is_loopback() {
210 return true;
211 }
212 let segments = ip.segments();
213 if segments[0] & 0xfe00 == 0xfc00 {
215 return true;
216 }
217 if segments[0] & 0xffc0 == 0xfe80 {
219 return true;
220 }
221 if segments[0] & 0xff00 == 0xff00 {
223 return true;
224 }
225 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 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}