Skip to main content

seer_core/
validation.rs

1//! Domain validation and SSRF protection utilities
2
3use std::collections::HashSet;
4use std::net::IpAddr;
5
6use std::sync::LazyLock;
7
8use crate::error::{Result, SeerError};
9
10/// Domain allowlist loaded from the `SEER_DOMAIN_ALLOWLIST` environment
11/// variable. Entries match by label-boundary suffix (see
12/// [`domain_matches_allowlist`]): `com` permits all `*.com`, and `example.com`
13/// permits `example.com` and its subdomains. When unset, all domains are
14/// allowed.
15static 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
30/// Normalizes a single allowlist entry: trims, lowercases, and converts an IDN
31/// entry to its ASCII A-label. This must mirror the IDN handling in
32/// [`normalize_domain`] so an internationalized entry matches the punycode form
33/// that queries are normalized to (an entry that fails IDN conversion is kept
34/// as-is and simply won't match).
35fn 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
44/// Normalizes and validates a domain name.
45///
46/// This function:
47/// - Removes http:// and https:// prefixes
48/// - Removes www. prefix (only when a registrable name remains — `www.com`
49///   is itself a domain and is kept whole)
50/// - Removes trailing slashes and paths
51/// - Converts to lowercase
52/// - Converts internationalized domain names (IDN) to Punycode (ASCII)
53/// - Validates format (must contain dots, only alphanumeric/hyphens/dots)
54/// - Does NOT perform SSRF checks. For network operations, resolve and
55///   validate via `crate::net::resolve_public_host` (or `validate_public_host`),
56///   which returns the vetted `SocketAddr`s to connect to — closing the
57///   resolve-then-connect (DNS-rebinding) window.
58///
59/// The `www.` strip is right for registration-level questions (WHOIS, RDAP,
60/// availability, history keys), where `www.example.com` means
61/// `example.com`. It is wrong wherever the exact name matters — a DNS record
62/// query or a per-host probe — because `www` routinely carries its own
63/// records (typically a CNAME). Use [`normalize_host`] there.
64pub fn normalize_domain(domain: &str) -> Result<String> {
65    normalize(domain, NameKind::Domain)
66}
67
68/// Normalizes and validates a hostname exactly like [`normalize_domain`], but
69/// keeps a leading `www.` label.
70///
71/// For operations about one specific DNS name rather than its registration:
72/// record lookups (`dig www.example.com CNAME` must query `www`, not the
73/// apex), propagation/follow/compare, and per-host probes such as takeover
74/// detection, where `www` is the single most commonly CNAME'd host.
75pub fn normalize_host(host: &str) -> Result<String> {
76    normalize(host, NameKind::Host)
77}
78
79/// Normalizes a DNS query name exactly like [`normalize_host`], but also
80/// accepts the RFC 4592 wildcard label: `*` as the whole leftmost label
81/// (`*.example.com`).
82///
83/// Querying a wildcard owner name is ordinary DNS (`dig *.example.com`
84/// returns the wildcard's own records), so record lookups and the other
85/// per-name DNS paths accept it. Per-host probes (`ssl`, `status`, …) keep
86/// [`normalize_host`]: `*` names no host they could connect to. A `*`
87/// anywhere else (`a*.example.com`, `a.*.example.com`) is still rejected.
88/// A single label is accepted when written absolute (`com.`), so a TLD's own
89/// records can be queried.
90pub fn normalize_query_name(name: &str) -> Result<String> {
91    normalize(name, NameKind::QueryName)
92}
93
94/// The name shapes [`normalize`] accepts.
95#[derive(Clone, Copy, PartialEq, Eq)]
96enum NameKind {
97    /// A registration-level domain: a leading `www.` is dropped.
98    Domain,
99    /// One specific host: `www.` is kept.
100    Host,
101    /// A DNS query name: `www.` is kept and the leftmost label may be `*`.
102    QueryName,
103}
104
105fn normalize(domain: &str, kind: NameKind) -> Result<String> {
106    // IDNA (UTS #46) maps the ideographic and full-width full stops to `.`.
107    // Map them first: every structural check below splits on ASCII dots.
108    let domain = domain
109        .trim()
110        .to_lowercase()
111        .replace(['\u{3002}', '\u{FF0E}', '\u{FF61}'], ".");
112
113    // Remove protocol
114    let domain = domain
115        .strip_prefix("http://")
116        .or_else(|| domain.strip_prefix("https://"))
117        .unwrap_or(&domain);
118
119    // Remove trailing slash, path, query parameters, and fragments
120    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    // Strip userinfo (`user:pass@host`) — take the host portion after the
125    // last '@'. This runs after path stripping so a stray '@' in a path
126    // segment can't affect the host.
127    let domain = domain.rsplit('@').next().unwrap_or(domain);
128
129    // Strip a trailing port (`host:8443`) but only when it is `:` followed
130    // entirely by digits, so we never truncate anything else (and IPv6
131    // literals, which are not valid here anyway, won't match this shape).
132    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    // Strip a single trailing dot (FQDN form: `example.com.` → `example.com`).
138    // DNS libraries and copy-paste from `dig` output routinely include the
139    // root-label dot; rejecting it would force callers to pre-clean inputs
140    // that are otherwise valid. Done before the `www.` strip so `www.com.`
141    // is judged on its real label count.
142    let absolute = domain.ends_with('.');
143    let domain = domain.strip_suffix('.').unwrap_or(domain);
144
145    // Remove www. prefix — but only when what remains still has a dot.
146    // `www.com`, `www.net`, … are registered domains in their own right;
147    // stripping them would leave a bare TLD and reject a valid query.
148    let domain = match domain.strip_prefix("www.") {
149        Some(rest) if kind == NameKind::Domain && rest.contains('.') => rest,
150        _ => domain,
151    };
152
153    // Validate domain format. A single label is a name only as a query name
154    // written absolute (`com.`, `mx.`): dig-style TLD and root-zone queries.
155    // Bare, it is far more likely a typo than a TLD.
156    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    // Convert internationalized domain names (IDN) to ASCII/Punycode
162    let domain = if !domain.is_ascii() {
163        domain_to_ascii(domain)?
164    } else {
165        domain.to_string()
166    };
167
168    // A query name may lead with the RFC 4592 wildcard label. Only the whole
169    // leftmost label qualifies; the labels after it are validated below like
170    // any other name, and the length and allowlist checks see the full name.
171    let labels = match domain.strip_prefix("*.") {
172        Some(rest) if kind == NameKind::QueryName => rest,
173        _ => domain.as_str(),
174    };
175
176    // Basic validation - alphanumeric, hyphens, dots, and underscores
177    // Underscores are valid in DNS names (RFC 8552) and required for service
178    // records like _dmarc., _domainkey., _sip._tcp., etc.
179    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    // Check for consecutive dots or dots at start/end
187    if labels.contains("..") || labels.starts_with('.') || labels.ends_with('.') {
188        return Err(SeerError::InvalidDomain(domain.to_string()));
189    }
190
191    // RFC 1035: total domain name length ≤ 253 characters
192    if domain.len() > 253 {
193        return Err(SeerError::InvalidDomain(domain.to_string()));
194    }
195
196    // Check label constraints
197    for label in labels.split('.') {
198        // Labels must be non-empty and not start/end with hyphens
199        if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
200            return Err(SeerError::InvalidDomain(domain.to_string()));
201        }
202        // RFC 1035: each label ≤ 63 characters
203        if label.len() > 63 {
204            return Err(SeerError::InvalidDomain(domain.to_string()));
205        }
206    }
207
208    // Check against the allowlist (if configured) using suffix matching.
209    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
222/// Returns true if `domain` is permitted by `allowlist` using label-boundary
223/// suffix matching: an entry matches the domain itself or any subdomain of it.
224///
225/// This fixes the misleading control where `SEER_DOMAIN_ALLOWLIST` only compared
226/// the final label, so a full-domain entry like `example.com` matched nothing
227/// (issue #61). A bare-TLD entry (e.g. `com`) still matches all `*.com` domains,
228/// so existing TLD-style configs keep working; a full-domain entry (e.g.
229/// `example.com`) now matches `example.com` and `sub.example.com` as the name
230/// implies. Both `domain` and the entries are already lowercased.
231fn 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        // Subdomain match: `domain` ends with ".<entry>" on a label boundary.
237        domain.len() > entry.len()
238            && domain.ends_with(entry.as_str())
239            && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
240    })
241}
242
243/// Converts an internationalized domain name to ASCII (Punycode).
244pub(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
250/// Returns a human-readable reason why an IP is blocked, or `None` if it is
251/// safe.  Intended for error messages — callers should still use
252/// [`crate::net::is_reserved_ip`], the single source of truth for SSRF range
253/// checks, for the fast boolean check.
254pub 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            // The metadata endpoint sits inside 169.254.0.0/16, so it must be
267            // checked before the generic link-local branch or it is never
268            // reported by name.
269            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            // Catch-all: any range the canonical checker blocks but for which we
292            // have no specific wording (CGNAT 100.64/10, IETF 192.0.0.0/24,
293            // benchmark 198.18/15, 0.0.0.0/8, …) is still refused — never
294            // under-block relative to net::is_reserved_ip.
295            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            // Catch-all for 6to4 / NAT64 / IPv4-compatible / documentation and
326            // any other range the canonical checker blocks.
327            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        // IDN allowlist entries are converted to their A-label so they match
342        // the punycode query produced by `normalize_domain`.
343        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        // `normalize_domain("münchen.de")` yields the A-label below.
352        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        // Query parameters and fragments
373        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        // Underscore domains (DNS service records)
387        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        // Invalid domains
401        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        // Port and userinfo are stripped from the host.
409        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        // FQDN form is accepted: single trailing dot is stripped.
424        assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
425        assert_eq!(
426            normalize_domain("https://example.com.").unwrap(),
427            "example.com"
428        );
429        // Double trailing dot is still invalid (would leave a trailing dot
430        // after stripping just one).
431        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        // `www.com` / `www.net` are real registered domains; stripping `www.`
437        // used to leave `com` and reject the query outright.
438        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        // A registrable name after the strip still normalizes to the apex.
442        assert_eq!(normalize_domain("www.example.com.").unwrap(), "example.com");
443    }
444
445    #[test]
446    fn normalize_host_keeps_www() {
447        // Per-name operations (DNS record queries, takeover probes) must see
448        // `www` itself — it usually has its own CNAME.
449        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        // Everything else matches normalize_domain.
455        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        // A wildcard directly under a TLD is a legitimate query too.
475        assert_eq!(normalize_query_name("*.com").unwrap(), "*.com");
476        // The labels after the wildcard still get IDN conversion.
477        assert_eq!(
478            normalize_query_name("*.münchen.de").unwrap(),
479            "*.xn--mnchen-3ya.de"
480        );
481        // Everything else normalizes exactly like `normalize_host`.
482        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        // `*` names no host to connect to (ssl, status, headers) and no
511        // registration (whois, rdap), so only DNS query names accept it.
512        assert!(normalize_host("*.example.com").is_err());
513        assert!(normalize_domain("*.example.com").is_err());
514    }
515
516    /// Regression: `seer dig com. NS` (and the `mx.` form the dig usage
517    /// hint suggests) failed as an invalid name, so a TLD's own records could
518    /// not be queried.
519    #[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        // Bare, a single label is still rejected (most likely a typo) …
524        assert!(normalize_query_name("com").is_err());
525        // … and hosts and registrations never take one.
526        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        // 169.254.169.254 is link-local too; the specific wording must win.
534        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    /// Regression: the IDNA full stops were rejected by the ASCII-dot check
545    /// that ran before IDNA mapping.
546    #[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        // German: münchen.de -> xn--mnchen-3ya.de
564        let result = normalize_domain("münchen.de").unwrap();
565        assert_eq!(result, "xn--mnchen-3ya.de");
566
567        // Japanese: 例え.jp -> xn--r8jz45g.jp
568        let result = normalize_domain("例え.jp").unwrap();
569        assert_eq!(result, "xn--r8jz45g.jp");
570
571        // Chinese: 中文.com -> xn--fiq228c.com
572        let result = normalize_domain("中文.com").unwrap();
573        assert_eq!(result, "xn--fiq228c.com");
574
575        // With protocol prefix
576        let result = normalize_domain("https://münchen.de/path").unwrap();
577        assert_eq!(result, "xn--mnchen-3ya.de");
578
579        // Cyrillic, including uppercase input (lowercased before conversion)
580        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        // Already-punycode input passes through unchanged
590        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        // When SEER_DOMAIN_ALLOWLIST is not set, all domains pass
599        // This test verifies the default behavior (no env var)
600        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        // #61: a full-domain entry must match the domain and its subdomains
608        // (the previous code only matched the final label, so "example.com"
609        // matched nothing), while a bare-TLD entry stays backward compatible.
610        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        // These were blocked by net::is_reserved_ip but NOT by the old
656        // validation checker that guards the RDAP + HTTP-redirect paths.
657        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        // IPv6 transition forms embedding the metadata IP (169.254.169.254).
672        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        // A genuinely public address is still allowed through.
680        assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
681    }
682}