1use std::collections::HashSet;
4use std::net::IpAddr;
5
6use std::sync::LazyLock;
7
8use crate::error::{Result, SeerError};
9
10static DOMAIN_ALLOWLIST: LazyLock<Option<HashSet<String>>> = LazyLock::new(|| {
16 let set: HashSet<String> = std::env::var("SEER_DOMAIN_ALLOWLIST")
17 .ok()?
18 .split(',')
19 .map(normalize_allowlist_entry)
20 .filter(|s| !s.is_empty())
21 .collect();
22
23 if set.is_empty() {
24 None
25 } else {
26 Some(set)
27 }
28});
29
30fn normalize_allowlist_entry(entry: &str) -> String {
36 let lowered = entry.trim().to_lowercase();
37 if lowered.is_ascii() {
38 lowered
39 } else {
40 domain_to_ascii(&lowered).unwrap_or(lowered)
41 }
42}
43
44pub fn normalize_domain(domain: &str) -> Result<String> {
65 normalize(domain, NameKind::Domain)
66}
67
68pub fn normalize_host(host: &str) -> Result<String> {
76 normalize(host, NameKind::Host)
77}
78
79pub fn normalize_query_name(name: &str) -> Result<String> {
91 normalize(name, NameKind::QueryName)
92}
93
94#[derive(Clone, Copy, PartialEq, Eq)]
96enum NameKind {
97 Domain,
99 Host,
101 QueryName,
103}
104
105fn normalize(domain: &str, kind: NameKind) -> Result<String> {
106 let domain = domain
109 .trim()
110 .to_lowercase()
111 .replace(['\u{3002}', '\u{FF0E}', '\u{FF61}'], ".");
112
113 let domain = domain
115 .strip_prefix("http://")
116 .or_else(|| domain.strip_prefix("https://"))
117 .unwrap_or(&domain);
118
119 let domain = domain.split('/').next().unwrap_or(domain);
121 let domain = domain.split('?').next().unwrap_or(domain);
122 let domain = domain.split('#').next().unwrap_or(domain);
123
124 let domain = domain.rsplit('@').next().unwrap_or(domain);
128
129 let domain = match domain.rsplit_once(':') {
133 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
134 _ => domain,
135 };
136
137 let absolute = domain.ends_with('.');
143 let domain = domain.strip_suffix('.').unwrap_or(domain);
144
145 let domain = match domain.strip_prefix("www.") {
149 Some(rest) if kind == NameKind::Domain && rest.contains('.') => rest,
150 _ => domain,
151 };
152
153 let single_label_ok = kind == NameKind::QueryName && absolute;
157 if domain.is_empty() || (!domain.contains('.') && !single_label_ok) {
158 return Err(SeerError::InvalidDomain(domain.to_string()));
159 }
160
161 let domain = if !domain.is_ascii() {
163 domain_to_ascii(domain)?
164 } else {
165 domain.to_string()
166 };
167
168 let labels = match domain.strip_prefix("*.") {
172 Some(rest) if kind == NameKind::QueryName => rest,
173 _ => domain.as_str(),
174 };
175
176 let valid = labels
180 .chars()
181 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
182 if !valid {
183 return Err(SeerError::InvalidDomain(domain.to_string()));
184 }
185
186 if labels.contains("..") || labels.starts_with('.') || labels.ends_with('.') {
188 return Err(SeerError::InvalidDomain(domain.to_string()));
189 }
190
191 if domain.len() > 253 {
193 return Err(SeerError::InvalidDomain(domain.to_string()));
194 }
195
196 for label in labels.split('.') {
198 if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
200 return Err(SeerError::InvalidDomain(domain.to_string()));
201 }
202 if label.len() > 63 {
204 return Err(SeerError::InvalidDomain(domain.to_string()));
205 }
206 }
207
208 if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
210 if !domain_matches_allowlist(&domain, allowlist) {
211 let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
212 return Err(SeerError::DomainNotAllowed {
213 domain: domain.to_string(),
214 tld,
215 });
216 }
217 }
218
219 Ok(domain.to_string())
220}
221
222fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
232 allowlist.iter().any(|entry| {
233 if domain == entry.as_str() {
234 return true;
235 }
236 domain.len() > entry.len()
238 && domain.ends_with(entry.as_str())
239 && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
240 })
241}
242
243pub(crate) fn domain_to_ascii(domain: &str) -> Result<String> {
245 idna::domain_to_ascii(domain).map_err(|_| {
246 SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
247 })
248}
249
250pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
255 match ip {
256 IpAddr::V4(v4) => {
257 if v4.is_unspecified() {
258 return Some("unspecified address (0.0.0.0) — domain has no routable IP");
259 }
260 if v4.is_loopback() {
261 return Some("loopback address (127.0.0.0/8)");
262 }
263 if v4.is_private() {
264 return Some("private network (RFC 1918)");
265 }
266 let o = v4.octets();
270 if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
271 return Some("cloud metadata endpoint (169.254.169.254)");
272 }
273 if v4.is_link_local() {
274 return Some("link-local address (169.254.0.0/16)");
275 }
276 if (o[0] == 192 && o[1] == 0 && o[2] == 2)
277 || (o[0] == 198 && o[1] == 51 && o[2] == 100)
278 || (o[0] == 203 && o[1] == 0 && o[2] == 113)
279 {
280 return Some("documentation/test range (RFC 5737)");
281 }
282 if v4.is_broadcast() {
283 return Some("broadcast address (255.255.255.255)");
284 }
285 if o[0] >= 224 && o[0] <= 239 {
286 return Some("multicast address (224.0.0.0/4)");
287 }
288 if o[0] >= 240 {
289 return Some("reserved address (240.0.0.0/4)");
290 }
291 if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
296 return Some("reserved/special-purpose address range");
297 }
298 None
299 }
300 IpAddr::V6(v6) => {
301 if v6.is_loopback() {
302 return Some("IPv6 loopback (::1)");
303 }
304 if v6.is_unspecified() {
305 return Some("IPv6 unspecified address (::) — domain has no routable IP");
306 }
307 let seg = v6.segments();
308 if (seg[0] & 0xfe00) == 0xfc00 {
309 return Some("IPv6 unique local address (fc00::/7)");
310 }
311 if (seg[0] & 0xffc0) == 0xfe80 {
312 return Some("IPv6 link-local address (fe80::/10)");
313 }
314 if (seg[0] & 0xffc0) == 0xfec0 {
315 return Some("IPv6 site-local address (fec0::/10)");
316 }
317 if seg[0] >> 8 == 0xff {
318 return Some("IPv6 multicast (ff00::/8)");
319 }
320 if let Some(v4) = v6.to_ipv4_mapped() {
321 if crate::net::is_reserved_ip(IpAddr::V4(v4)) {
322 return Some("IPv4-mapped IPv6 address in private/reserved range");
323 }
324 }
325 if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
328 return Some("reserved/special-purpose IPv6 range");
329 }
330 None
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn allowlist_entry_idn_is_punycoded() {
341 assert_eq!(normalize_allowlist_entry("münchen.de"), "xn--mnchen-3ya.de");
344 assert_eq!(normalize_allowlist_entry(" Example.COM "), "example.com");
345 }
346
347 #[test]
348 fn allowlist_matches_idn_after_normalization() {
349 let mut set = HashSet::new();
350 set.insert(normalize_allowlist_entry("münchen.de"));
351 assert!(domain_matches_allowlist("xn--mnchen-3ya.de", &set));
353 }
354
355 #[test]
356 fn test_normalize_domain() {
357 assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
358 assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
359 assert_eq!(
360 normalize_domain("https://www.example.com/path").unwrap(),
361 "example.com"
362 );
363 assert_eq!(
364 normalize_domain("http://example.com/").unwrap(),
365 "example.com"
366 );
367 assert_eq!(
368 normalize_domain(" WWW.EXAMPLE.COM ").unwrap(),
369 "example.com"
370 );
371
372 assert_eq!(
374 normalize_domain("example.com?query=1").unwrap(),
375 "example.com"
376 );
377 assert_eq!(
378 normalize_domain("example.com#section").unwrap(),
379 "example.com"
380 );
381 assert_eq!(
382 normalize_domain("https://example.com/path?q=1#frag").unwrap(),
383 "example.com"
384 );
385
386 assert_eq!(
388 normalize_domain("_dmarc.example.com").unwrap(),
389 "_dmarc.example.com"
390 );
391 assert_eq!(
392 normalize_domain("selector1._domainkey.example.com").unwrap(),
393 "selector1._domainkey.example.com"
394 );
395 assert_eq!(
396 normalize_domain("_sip._tcp.example.com").unwrap(),
397 "_sip._tcp.example.com"
398 );
399
400 assert!(normalize_domain("").is_err());
402 assert!(normalize_domain("nodots").is_err());
403 assert!(normalize_domain("example..com").is_err());
404 assert!(normalize_domain(".example.com").is_err());
405 assert!(normalize_domain("-example.com").is_err());
406 assert!(normalize_domain("example-.com").is_err());
407
408 assert_eq!(
410 normalize_domain("https://example.com:8443/admin").unwrap(),
411 "example.com"
412 );
413 assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
414 assert_eq!(
415 normalize_domain("https://user@example.com/").unwrap(),
416 "example.com"
417 );
418 assert_eq!(
419 normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
420 "example.com"
421 );
422
423 assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
425 assert_eq!(
426 normalize_domain("https://example.com.").unwrap(),
427 "example.com"
428 );
429 assert!(normalize_domain("example.com..").is_err());
432 }
433
434 #[test]
435 fn www_is_not_stripped_when_it_would_leave_a_bare_tld() {
436 assert_eq!(normalize_domain("www.com").unwrap(), "www.com");
439 assert_eq!(normalize_domain("WWW.NET.").unwrap(), "www.net");
440 assert_eq!(normalize_domain("https://www.io/x").unwrap(), "www.io");
441 assert_eq!(normalize_domain("www.example.com.").unwrap(), "example.com");
443 }
444
445 #[test]
446 fn normalize_host_keeps_www() {
447 assert_eq!(
450 normalize_host("HTTPS://WWW.Example.com:443/path").unwrap(),
451 "www.example.com"
452 );
453 assert_eq!(normalize_host("www.com").unwrap(), "www.com");
454 assert_eq!(
456 normalize_host("sub.example.com.").unwrap(),
457 "sub.example.com"
458 );
459 assert_eq!(normalize_host("münchen.de").unwrap(), "xn--mnchen-3ya.de");
460 assert!(normalize_host("nodots").is_err());
461 assert!(normalize_host("bad_label-.com").is_err());
462 }
463
464 #[test]
465 fn normalize_query_name_accepts_a_leading_wildcard_label() {
466 assert_eq!(
467 normalize_query_name("*.Example.COM.").unwrap(),
468 "*.example.com"
469 );
470 assert_eq!(
471 normalize_query_name("*.www.example.com").unwrap(),
472 "*.www.example.com"
473 );
474 assert_eq!(normalize_query_name("*.com").unwrap(), "*.com");
476 assert_eq!(
478 normalize_query_name("*.münchen.de").unwrap(),
479 "*.xn--mnchen-3ya.de"
480 );
481 assert_eq!(
483 normalize_query_name("https://WWW.Example.com:443/x").unwrap(),
484 "www.example.com"
485 );
486 }
487
488 #[test]
489 fn normalize_query_name_rejects_a_misplaced_or_partial_wildcard() {
490 for bad in [
491 "*",
492 "*.",
493 "**.example.com",
494 "*a.example.com",
495 "a*.example.com",
496 "a.*.example.com",
497 "*.*.example.com",
498 "*..example.com",
499 "*.-bad.example.com",
500 ] {
501 assert!(
502 normalize_query_name(bad).is_err(),
503 "{bad:?} must be rejected"
504 );
505 }
506 }
507
508 #[test]
509 fn host_and_domain_normalizers_still_reject_wildcards() {
510 assert!(normalize_host("*.example.com").is_err());
513 assert!(normalize_domain("*.example.com").is_err());
514 }
515
516 #[test]
520 fn an_absolute_single_label_is_a_query_name_only() {
521 assert_eq!(normalize_query_name("com.").unwrap(), "com");
522 assert_eq!(normalize_query_name("MX.").unwrap(), "mx");
523 assert!(normalize_query_name("com").is_err());
525 assert!(normalize_host("com.").is_err());
527 assert!(normalize_domain("com.").is_err());
528 assert!(normalize_query_name(".").is_err());
529 }
530
531 #[test]
532 fn describe_reserved_ip_names_the_metadata_endpoint() {
533 assert_eq!(
535 describe_reserved_ip(&"169.254.169.254".parse().unwrap()),
536 Some("cloud metadata endpoint (169.254.169.254)")
537 );
538 assert_eq!(
539 describe_reserved_ip(&"169.254.1.1".parse().unwrap()),
540 Some("link-local address (169.254.0.0/16)")
541 );
542 }
543
544 #[test]
547 fn idna_full_stops_separate_labels() {
548 for input in ["例え\u{3002}jp", "例え\u{FF0E}jp", "例え\u{FF61}jp"] {
549 assert_eq!(
550 normalize_domain(input).unwrap(),
551 "xn--r8jz45g.jp",
552 "{input:?}"
553 );
554 }
555 assert_eq!(
556 normalize_host("www\u{3002}example\u{3002}com").unwrap(),
557 "www.example.com"
558 );
559 }
560
561 #[test]
562 fn test_normalize_idn_domain() {
563 let result = normalize_domain("münchen.de").unwrap();
565 assert_eq!(result, "xn--mnchen-3ya.de");
566
567 let result = normalize_domain("例え.jp").unwrap();
569 assert_eq!(result, "xn--r8jz45g.jp");
570
571 let result = normalize_domain("中文.com").unwrap();
573 assert_eq!(result, "xn--fiq228c.com");
574
575 let result = normalize_domain("https://münchen.de/path").unwrap();
577 assert_eq!(result, "xn--mnchen-3ya.de");
578
579 assert_eq!(
581 normalize_domain("пример.рф").unwrap(),
582 "xn--e1afmkfd.xn--p1ai"
583 );
584 assert_eq!(
585 normalize_domain("ПРИМЕР.РФ").unwrap(),
586 "xn--e1afmkfd.xn--p1ai"
587 );
588
589 assert_eq!(
591 normalize_domain("xn--e1afmkfd.xn--p1ai").unwrap(),
592 "xn--e1afmkfd.xn--p1ai"
593 );
594 }
595
596 #[test]
597 fn test_allowlist_not_set_allows_all() {
598 assert!(normalize_domain("example.com").is_ok());
601 assert!(normalize_domain("example.xyz").is_ok());
602 assert!(normalize_domain("example.co.uk").is_ok());
603 }
604
605 #[test]
606 fn allowlist_suffix_matching() {
607 let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
611 assert!(domain_matches_allowlist("example.com", &full));
612 assert!(domain_matches_allowlist("sub.example.com", &full));
613 assert!(
614 !domain_matches_allowlist("notexample.com", &full),
615 "label boundary"
616 );
617 assert!(!domain_matches_allowlist("example.org", &full));
618
619 let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
620 assert!(
621 domain_matches_allowlist("example.com", &tld),
622 "bare TLD still works"
623 );
624 assert!(domain_matches_allowlist("a.b.com", &tld));
625 assert!(domain_matches_allowlist("com", &tld));
626 assert!(!domain_matches_allowlist("example.org", &tld));
627 assert!(
628 !domain_matches_allowlist("scam", &tld),
629 "must not match mid-label"
630 );
631 }
632
633 #[test]
634 fn reserved_ip_check_blocks_private_ranges_only() {
635 for ip in [
636 "10.0.0.1",
637 "172.16.0.1",
638 "192.168.1.1",
639 "127.0.0.1",
640 "169.254.1.1",
641 "169.254.169.254",
642 "::1",
643 "fc00::1",
644 "fe80::1",
645 ] {
646 assert!(crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
647 }
648 for ip in ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"] {
649 assert!(!crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
650 }
651 }
652
653 #[test]
654 fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
655 for ip in [
658 "100.64.0.1",
659 "198.18.0.1",
660 "192.0.0.1",
661 "0.1.2.3",
662 "240.0.0.1",
663 ] {
664 let addr: IpAddr = ip.parse().unwrap();
665 assert!(
666 describe_reserved_ip(&addr).is_some(),
667 "{ip} must be reported reserved"
668 );
669 assert!(crate::net::is_reserved_ip(addr), "{ip} bool check");
670 }
671 for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
673 let addr: IpAddr = ip.parse().unwrap();
674 assert!(
675 describe_reserved_ip(&addr).is_some(),
676 "{ip} must be reported reserved"
677 );
678 }
679 assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
681 }
682}