1use std::collections::HashSet;
4use std::net::{IpAddr, Ipv4Addr};
5
6use once_cell::sync::Lazy;
7
8use crate::error::{Result, SeerError};
9
10static DOMAIN_ALLOWLIST: Lazy<Option<HashSet<String>>> = Lazy::new(|| {
16 let set: HashSet<String> = std::env::var("SEER_DOMAIN_ALLOWLIST")
17 .ok()?
18 .split(',')
19 .map(|s| s.trim().to_lowercase())
20 .filter(|s| !s.is_empty())
21 .collect();
22
23 if set.is_empty() {
24 None
25 } else {
26 Some(set)
27 }
28});
29
30pub fn normalize_domain(domain: &str) -> Result<String> {
44 let domain = domain.trim().to_lowercase();
45
46 let domain = domain
48 .strip_prefix("http://")
49 .or_else(|| domain.strip_prefix("https://"))
50 .unwrap_or(&domain);
51
52 let domain = domain.split('/').next().unwrap_or(domain);
54 let domain = domain.split('?').next().unwrap_or(domain);
55 let domain = domain.split('#').next().unwrap_or(domain);
56
57 let domain = domain.rsplit('@').next().unwrap_or(domain);
61
62 let domain = match domain.rsplit_once(':') {
66 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
67 _ => domain,
68 };
69
70 let domain = domain.strip_prefix("www.").unwrap_or(domain);
72
73 let domain = domain.strip_suffix('.').unwrap_or(domain);
78
79 if domain.is_empty() || !domain.contains('.') {
81 return Err(SeerError::InvalidDomain(domain.to_string()));
82 }
83
84 let domain = if !domain.is_ascii() {
86 domain_to_ascii(domain)?
87 } else {
88 domain.to_string()
89 };
90
91 let valid = domain
95 .chars()
96 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
97 if !valid {
98 return Err(SeerError::InvalidDomain(domain.to_string()));
99 }
100
101 if domain.contains("..") || domain.starts_with('.') || domain.ends_with('.') {
103 return Err(SeerError::InvalidDomain(domain.to_string()));
104 }
105
106 if domain.len() > 253 {
108 return Err(SeerError::InvalidDomain(domain.to_string()));
109 }
110
111 for label in domain.split('.') {
113 if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
115 return Err(SeerError::InvalidDomain(domain.to_string()));
116 }
117 if label.len() > 63 {
119 return Err(SeerError::InvalidDomain(domain.to_string()));
120 }
121 }
122
123 if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
125 if !domain_matches_allowlist(&domain, allowlist) {
126 let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
127 return Err(SeerError::DomainNotAllowed {
128 domain: domain.to_string(),
129 tld,
130 });
131 }
132 }
133
134 Ok(domain.to_string())
135}
136
137fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
147 allowlist.iter().any(|entry| {
148 if domain == entry.as_str() {
149 return true;
150 }
151 domain.len() > entry.len()
153 && domain.ends_with(entry.as_str())
154 && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
155 })
156}
157
158fn domain_to_ascii(domain: &str) -> Result<String> {
160 idna::domain_to_ascii(domain).map_err(|_| {
161 SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
162 })
163}
164
165pub fn is_private_or_reserved_ip(ip: &IpAddr) -> bool {
175 crate::net::is_reserved_ip(*ip)
176}
177
178fn is_private_or_reserved_ipv4(ip: &Ipv4Addr) -> bool {
183 crate::net::is_reserved_ip(IpAddr::V4(*ip))
184}
185
186pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
190 match ip {
191 IpAddr::V4(v4) => {
192 if v4.is_unspecified() {
193 return Some("unspecified address (0.0.0.0) — domain has no routable IP");
194 }
195 if v4.is_loopback() {
196 return Some("loopback address (127.0.0.0/8)");
197 }
198 if v4.is_private() {
199 return Some("private network (RFC 1918)");
200 }
201 if v4.is_link_local() {
202 return Some("link-local address (169.254.0.0/16)");
203 }
204 let o = v4.octets();
205 if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
206 return Some("cloud metadata endpoint (169.254.169.254)");
207 }
208 if o[0] == 169 && o[1] == 254 {
209 return Some("link-local address (169.254.0.0/16)");
210 }
211 if (o[0] == 192 && o[1] == 0 && o[2] == 2)
212 || (o[0] == 198 && o[1] == 51 && o[2] == 100)
213 || (o[0] == 203 && o[1] == 0 && o[2] == 113)
214 {
215 return Some("documentation/test range (RFC 5737)");
216 }
217 if v4.is_broadcast() {
218 return Some("broadcast address (255.255.255.255)");
219 }
220 if o[0] >= 224 && o[0] <= 239 {
221 return Some("multicast address (224.0.0.0/4)");
222 }
223 if o[0] >= 240 {
224 return Some("reserved address (240.0.0.0/4)");
225 }
226 if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
231 return Some("reserved/special-purpose address range");
232 }
233 None
234 }
235 IpAddr::V6(v6) => {
236 if v6.is_loopback() {
237 return Some("IPv6 loopback (::1)");
238 }
239 if v6.is_unspecified() {
240 return Some("IPv6 unspecified address (::) — domain has no routable IP");
241 }
242 let seg = v6.segments();
243 if (seg[0] & 0xfe00) == 0xfc00 {
244 return Some("IPv6 unique local address (fc00::/7)");
245 }
246 if (seg[0] & 0xffc0) == 0xfe80 {
247 return Some("IPv6 link-local address (fe80::/10)");
248 }
249 if seg[0] >> 8 == 0xff {
250 return Some("IPv6 multicast (ff00::/8)");
251 }
252 if let Some(v4) = v6.to_ipv4_mapped() {
253 if is_private_or_reserved_ipv4(&v4) {
254 return Some("IPv4-mapped IPv6 address in private/reserved range");
255 }
256 }
257 if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
260 return Some("reserved/special-purpose IPv6 range");
261 }
262 None
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::net::Ipv6Addr;
271
272 #[test]
273 fn test_normalize_domain() {
274 assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
275 assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
276 assert_eq!(
277 normalize_domain("https://www.example.com/path").unwrap(),
278 "example.com"
279 );
280 assert_eq!(
281 normalize_domain("http://example.com/").unwrap(),
282 "example.com"
283 );
284 assert_eq!(
285 normalize_domain(" WWW.EXAMPLE.COM ").unwrap(),
286 "example.com"
287 );
288
289 assert_eq!(
291 normalize_domain("example.com?query=1").unwrap(),
292 "example.com"
293 );
294 assert_eq!(
295 normalize_domain("example.com#section").unwrap(),
296 "example.com"
297 );
298 assert_eq!(
299 normalize_domain("https://example.com/path?q=1#frag").unwrap(),
300 "example.com"
301 );
302
303 assert_eq!(
305 normalize_domain("_dmarc.example.com").unwrap(),
306 "_dmarc.example.com"
307 );
308 assert_eq!(
309 normalize_domain("selector1._domainkey.example.com").unwrap(),
310 "selector1._domainkey.example.com"
311 );
312 assert_eq!(
313 normalize_domain("_sip._tcp.example.com").unwrap(),
314 "_sip._tcp.example.com"
315 );
316
317 assert!(normalize_domain("").is_err());
319 assert!(normalize_domain("nodots").is_err());
320 assert!(normalize_domain("example..com").is_err());
321 assert!(normalize_domain(".example.com").is_err());
322 assert!(normalize_domain("-example.com").is_err());
323 assert!(normalize_domain("example-.com").is_err());
324
325 assert_eq!(
327 normalize_domain("https://example.com:8443/admin").unwrap(),
328 "example.com"
329 );
330 assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
331 assert_eq!(
332 normalize_domain("https://user@example.com/").unwrap(),
333 "example.com"
334 );
335 assert_eq!(
336 normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
337 "example.com"
338 );
339
340 assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
342 assert_eq!(
343 normalize_domain("https://example.com.").unwrap(),
344 "example.com"
345 );
346 assert!(normalize_domain("example.com..").is_err());
349 }
350
351 #[test]
352 fn test_normalize_idn_domain() {
353 let result = normalize_domain("münchen.de").unwrap();
355 assert_eq!(result, "xn--mnchen-3ya.de");
356
357 let result = normalize_domain("例え.jp").unwrap();
359 assert_eq!(result, "xn--r8jz45g.jp");
360
361 let result = normalize_domain("中文.com").unwrap();
363 assert_eq!(result, "xn--fiq228c.com");
364
365 let result = normalize_domain("https://münchen.de/path").unwrap();
367 assert_eq!(result, "xn--mnchen-3ya.de");
368 }
369
370 #[test]
371 fn test_allowlist_not_set_allows_all() {
372 assert!(normalize_domain("example.com").is_ok());
375 assert!(normalize_domain("example.xyz").is_ok());
376 assert!(normalize_domain("example.co.uk").is_ok());
377 }
378
379 #[test]
380 fn allowlist_suffix_matching() {
381 let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
385 assert!(domain_matches_allowlist("example.com", &full));
386 assert!(domain_matches_allowlist("sub.example.com", &full));
387 assert!(
388 !domain_matches_allowlist("notexample.com", &full),
389 "label boundary"
390 );
391 assert!(!domain_matches_allowlist("example.org", &full));
392
393 let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
394 assert!(
395 domain_matches_allowlist("example.com", &tld),
396 "bare TLD still works"
397 );
398 assert!(domain_matches_allowlist("a.b.com", &tld));
399 assert!(domain_matches_allowlist("com", &tld));
400 assert!(!domain_matches_allowlist("example.org", &tld));
401 assert!(
402 !domain_matches_allowlist("scam", &tld),
403 "must not match mid-label"
404 );
405 }
406
407 #[test]
408 fn test_is_private_or_reserved_ipv4() {
409 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
411 10, 0, 0, 1
412 ))));
413 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
414 172, 16, 0, 1
415 ))));
416 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
417 192, 168, 1, 1
418 ))));
419
420 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
422 127, 0, 0, 1
423 ))));
424
425 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
427 169, 254, 1, 1
428 ))));
429
430 assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
432 169, 254, 169, 254
433 ))));
434
435 assert!(!is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
437 8, 8, 8, 8
438 ))));
439 assert!(!is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
440 1, 1, 1, 1
441 ))));
442 }
443
444 #[test]
445 fn test_is_private_or_reserved_ipv6() {
446 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
448 0, 0, 0, 0, 0, 0, 0, 1
449 ))));
450
451 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
453 0xfc00, 0, 0, 0, 0, 0, 0, 1
454 ))));
455
456 assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
458 0xfe80, 0, 0, 0, 0, 0, 0, 1
459 ))));
460
461 assert!(!is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
463 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
464 ))));
465 }
466
467 #[test]
468 fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
469 for ip in [
472 "100.64.0.1",
473 "198.18.0.1",
474 "192.0.0.1",
475 "0.1.2.3",
476 "240.0.0.1",
477 ] {
478 let addr: IpAddr = ip.parse().unwrap();
479 assert!(
480 describe_reserved_ip(&addr).is_some(),
481 "{ip} must be reported reserved"
482 );
483 assert!(is_private_or_reserved_ip(&addr), "{ip} bool check");
484 }
485 for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
487 let addr: IpAddr = ip.parse().unwrap();
488 assert!(
489 describe_reserved_ip(&addr).is_some(),
490 "{ip} must be reported reserved"
491 );
492 }
493 assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
495 }
496}