swink_agent_plugin_web/
domain.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
2
3use thiserror::Error;
4use url::Url;
5
6#[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#[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 #[must_use]
43 pub fn blocking_private_ips() -> Self {
44 Self {
45 block_private_ips: true,
46 ..Self::default()
47 }
48 }
49
50 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 let scheme = url.scheme();
69 if scheme != "http" && scheme != "https" {
70 return Err(DomainFilterError::InvalidScheme(scheme.to_string()));
71 }
72
73 let host = url
75 .host_str()
76 .ok_or_else(|| DomainFilterError::InvalidUrl("URL has no host".to_string()))?;
77
78 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 if self.denylist.iter().any(|d| d.eq_ignore_ascii_case(host)) {
87 return Err(DomainFilterError::DeniedDomain(host.to_string()));
88 }
89
90 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
145fn 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 if octets[0] == 0 {
158 return true;
159 }
160 if octets[0] == 127 {
162 return true;
163 }
164 if octets[0] == 10 {
166 return true;
167 }
168 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
170 return true;
171 }
172 if octets[0] == 192 && octets[1] == 168 {
174 return true;
175 }
176 if octets[0] == 169 && octets[1] == 254 {
178 return true;
179 }
180 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
182 return true;
183 }
184 if octets[0] == 198 && (18..=19).contains(&octets[1]) {
186 return true;
187 }
188 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 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 if ip.is_unspecified() {
208 return true;
209 }
210 if ip.is_loopback() {
212 return true;
213 }
214 let segments = ip.segments();
215 if segments[0] & 0xfe00 == 0xfc00 {
217 return true;
218 }
219 if segments[0] & 0xffc0 == 0xfe80 {
221 return true;
222 }
223 if segments[0] & 0xff00 == 0xff00 {
225 return true;
226 }
227 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 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}