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, true)
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, false)
77}
78
79fn normalize(domain: &str, strip_www: bool) -> Result<String> {
80    let domain = domain.trim().to_lowercase();
81
82    // Remove protocol
83    let domain = domain
84        .strip_prefix("http://")
85        .or_else(|| domain.strip_prefix("https://"))
86        .unwrap_or(&domain);
87
88    // Remove trailing slash, path, query parameters, and fragments
89    let domain = domain.split('/').next().unwrap_or(domain);
90    let domain = domain.split('?').next().unwrap_or(domain);
91    let domain = domain.split('#').next().unwrap_or(domain);
92
93    // Strip userinfo (`user:pass@host`) — take the host portion after the
94    // last '@'. This runs after path stripping so a stray '@' in a path
95    // segment can't affect the host.
96    let domain = domain.rsplit('@').next().unwrap_or(domain);
97
98    // Strip a trailing port (`host:8443`) but only when it is `:` followed
99    // entirely by digits, so we never truncate anything else (and IPv6
100    // literals, which are not valid here anyway, won't match this shape).
101    let domain = match domain.rsplit_once(':') {
102        Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
103        _ => domain,
104    };
105
106    // Strip a single trailing dot (FQDN form: `example.com.` → `example.com`).
107    // DNS libraries and copy-paste from `dig` output routinely include the
108    // root-label dot; rejecting it would force callers to pre-clean inputs
109    // that are otherwise valid. Done before the `www.` strip so `www.com.`
110    // is judged on its real label count.
111    let domain = domain.strip_suffix('.').unwrap_or(domain);
112
113    // Remove www. prefix — but only when what remains still has a dot.
114    // `www.com`, `www.net`, … are registered domains in their own right;
115    // stripping them would leave a bare TLD and reject a valid query.
116    let domain = match domain.strip_prefix("www.") {
117        Some(rest) if strip_www && rest.contains('.') => rest,
118        _ => domain,
119    };
120
121    // Validate domain format
122    if domain.is_empty() || !domain.contains('.') {
123        return Err(SeerError::InvalidDomain(domain.to_string()));
124    }
125
126    // Convert internationalized domain names (IDN) to ASCII/Punycode
127    let domain = if !domain.is_ascii() {
128        domain_to_ascii(domain)?
129    } else {
130        domain.to_string()
131    };
132
133    // Basic validation - alphanumeric, hyphens, dots, and underscores
134    // Underscores are valid in DNS names (RFC 8552) and required for service
135    // records like _dmarc., _domainkey., _sip._tcp., etc.
136    let valid = domain
137        .chars()
138        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
139    if !valid {
140        return Err(SeerError::InvalidDomain(domain.to_string()));
141    }
142
143    // Check for consecutive dots or dots at start/end
144    if domain.contains("..") || domain.starts_with('.') || domain.ends_with('.') {
145        return Err(SeerError::InvalidDomain(domain.to_string()));
146    }
147
148    // RFC 1035: total domain name length ≤ 253 characters
149    if domain.len() > 253 {
150        return Err(SeerError::InvalidDomain(domain.to_string()));
151    }
152
153    // Check label constraints
154    for label in domain.split('.') {
155        // Labels must be non-empty and not start/end with hyphens
156        if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
157            return Err(SeerError::InvalidDomain(domain.to_string()));
158        }
159        // RFC 1035: each label ≤ 63 characters
160        if label.len() > 63 {
161            return Err(SeerError::InvalidDomain(domain.to_string()));
162        }
163    }
164
165    // Check against the allowlist (if configured) using suffix matching.
166    if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
167        if !domain_matches_allowlist(&domain, allowlist) {
168            let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
169            return Err(SeerError::DomainNotAllowed {
170                domain: domain.to_string(),
171                tld,
172            });
173        }
174    }
175
176    Ok(domain.to_string())
177}
178
179/// Returns true if `domain` is permitted by `allowlist` using label-boundary
180/// suffix matching: an entry matches the domain itself or any subdomain of it.
181///
182/// This fixes the misleading control where `SEER_DOMAIN_ALLOWLIST` only compared
183/// the final label, so a full-domain entry like `example.com` matched nothing
184/// (issue #61). A bare-TLD entry (e.g. `com`) still matches all `*.com` domains,
185/// so existing TLD-style configs keep working; a full-domain entry (e.g.
186/// `example.com`) now matches `example.com` and `sub.example.com` as the name
187/// implies. Both `domain` and the entries are already lowercased.
188fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
189    allowlist.iter().any(|entry| {
190        if domain == entry.as_str() {
191            return true;
192        }
193        // Subdomain match: `domain` ends with ".<entry>" on a label boundary.
194        domain.len() > entry.len()
195            && domain.ends_with(entry.as_str())
196            && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
197    })
198}
199
200/// Converts an internationalized domain name to ASCII (Punycode).
201pub(crate) fn domain_to_ascii(domain: &str) -> Result<String> {
202    idna::domain_to_ascii(domain).map_err(|_| {
203        SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
204    })
205}
206
207/// Returns a human-readable reason why an IP is blocked, or `None` if it is
208/// safe.  Intended for error messages — callers should still use
209/// [`crate::net::is_reserved_ip`], the single source of truth for SSRF range
210/// checks, for the fast boolean check.
211pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
212    match ip {
213        IpAddr::V4(v4) => {
214            if v4.is_unspecified() {
215                return Some("unspecified address (0.0.0.0) — domain has no routable IP");
216            }
217            if v4.is_loopback() {
218                return Some("loopback address (127.0.0.0/8)");
219            }
220            if v4.is_private() {
221                return Some("private network (RFC 1918)");
222            }
223            // The metadata endpoint sits inside 169.254.0.0/16, so it must be
224            // checked before the generic link-local branch or it is never
225            // reported by name.
226            let o = v4.octets();
227            if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
228                return Some("cloud metadata endpoint (169.254.169.254)");
229            }
230            if v4.is_link_local() {
231                return Some("link-local address (169.254.0.0/16)");
232            }
233            if (o[0] == 192 && o[1] == 0 && o[2] == 2)
234                || (o[0] == 198 && o[1] == 51 && o[2] == 100)
235                || (o[0] == 203 && o[1] == 0 && o[2] == 113)
236            {
237                return Some("documentation/test range (RFC 5737)");
238            }
239            if v4.is_broadcast() {
240                return Some("broadcast address (255.255.255.255)");
241            }
242            if o[0] >= 224 && o[0] <= 239 {
243                return Some("multicast address (224.0.0.0/4)");
244            }
245            if o[0] >= 240 {
246                return Some("reserved address (240.0.0.0/4)");
247            }
248            // Catch-all: any range the canonical checker blocks but for which we
249            // have no specific wording (CGNAT 100.64/10, IETF 192.0.0.0/24,
250            // benchmark 198.18/15, 0.0.0.0/8, …) is still refused — never
251            // under-block relative to net::is_reserved_ip.
252            if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
253                return Some("reserved/special-purpose address range");
254            }
255            None
256        }
257        IpAddr::V6(v6) => {
258            if v6.is_loopback() {
259                return Some("IPv6 loopback (::1)");
260            }
261            if v6.is_unspecified() {
262                return Some("IPv6 unspecified address (::) — domain has no routable IP");
263            }
264            let seg = v6.segments();
265            if (seg[0] & 0xfe00) == 0xfc00 {
266                return Some("IPv6 unique local address (fc00::/7)");
267            }
268            if (seg[0] & 0xffc0) == 0xfe80 {
269                return Some("IPv6 link-local address (fe80::/10)");
270            }
271            if (seg[0] & 0xffc0) == 0xfec0 {
272                return Some("IPv6 site-local address (fec0::/10)");
273            }
274            if seg[0] >> 8 == 0xff {
275                return Some("IPv6 multicast (ff00::/8)");
276            }
277            if let Some(v4) = v6.to_ipv4_mapped() {
278                if crate::net::is_reserved_ip(IpAddr::V4(v4)) {
279                    return Some("IPv4-mapped IPv6 address in private/reserved range");
280                }
281            }
282            // Catch-all for 6to4 / NAT64 / IPv4-compatible / documentation and
283            // any other range the canonical checker blocks.
284            if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
285                return Some("reserved/special-purpose IPv6 range");
286            }
287            None
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn allowlist_entry_idn_is_punycoded() {
298        // IDN allowlist entries are converted to their A-label so they match
299        // the punycode query produced by `normalize_domain`.
300        assert_eq!(normalize_allowlist_entry("münchen.de"), "xn--mnchen-3ya.de");
301        assert_eq!(normalize_allowlist_entry(" Example.COM "), "example.com");
302    }
303
304    #[test]
305    fn allowlist_matches_idn_after_normalization() {
306        let mut set = HashSet::new();
307        set.insert(normalize_allowlist_entry("münchen.de"));
308        // `normalize_domain("münchen.de")` yields the A-label below.
309        assert!(domain_matches_allowlist("xn--mnchen-3ya.de", &set));
310    }
311
312    #[test]
313    fn test_normalize_domain() {
314        assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
315        assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
316        assert_eq!(
317            normalize_domain("https://www.example.com/path").unwrap(),
318            "example.com"
319        );
320        assert_eq!(
321            normalize_domain("http://example.com/").unwrap(),
322            "example.com"
323        );
324        assert_eq!(
325            normalize_domain("  WWW.EXAMPLE.COM  ").unwrap(),
326            "example.com"
327        );
328
329        // Query parameters and fragments
330        assert_eq!(
331            normalize_domain("example.com?query=1").unwrap(),
332            "example.com"
333        );
334        assert_eq!(
335            normalize_domain("example.com#section").unwrap(),
336            "example.com"
337        );
338        assert_eq!(
339            normalize_domain("https://example.com/path?q=1#frag").unwrap(),
340            "example.com"
341        );
342
343        // Underscore domains (DNS service records)
344        assert_eq!(
345            normalize_domain("_dmarc.example.com").unwrap(),
346            "_dmarc.example.com"
347        );
348        assert_eq!(
349            normalize_domain("selector1._domainkey.example.com").unwrap(),
350            "selector1._domainkey.example.com"
351        );
352        assert_eq!(
353            normalize_domain("_sip._tcp.example.com").unwrap(),
354            "_sip._tcp.example.com"
355        );
356
357        // Invalid domains
358        assert!(normalize_domain("").is_err());
359        assert!(normalize_domain("nodots").is_err());
360        assert!(normalize_domain("example..com").is_err());
361        assert!(normalize_domain(".example.com").is_err());
362        assert!(normalize_domain("-example.com").is_err());
363        assert!(normalize_domain("example-.com").is_err());
364
365        // Port and userinfo are stripped from the host.
366        assert_eq!(
367            normalize_domain("https://example.com:8443/admin").unwrap(),
368            "example.com"
369        );
370        assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
371        assert_eq!(
372            normalize_domain("https://user@example.com/").unwrap(),
373            "example.com"
374        );
375        assert_eq!(
376            normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
377            "example.com"
378        );
379
380        // FQDN form is accepted: single trailing dot is stripped.
381        assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
382        assert_eq!(
383            normalize_domain("https://example.com.").unwrap(),
384            "example.com"
385        );
386        // Double trailing dot is still invalid (would leave a trailing dot
387        // after stripping just one).
388        assert!(normalize_domain("example.com..").is_err());
389    }
390
391    #[test]
392    fn www_is_not_stripped_when_it_would_leave_a_bare_tld() {
393        // `www.com` / `www.net` are real registered domains; stripping `www.`
394        // used to leave `com` and reject the query outright.
395        assert_eq!(normalize_domain("www.com").unwrap(), "www.com");
396        assert_eq!(normalize_domain("WWW.NET.").unwrap(), "www.net");
397        assert_eq!(normalize_domain("https://www.io/x").unwrap(), "www.io");
398        // A registrable name after the strip still normalizes to the apex.
399        assert_eq!(normalize_domain("www.example.com.").unwrap(), "example.com");
400    }
401
402    #[test]
403    fn normalize_host_keeps_www() {
404        // Per-name operations (DNS record queries, takeover probes) must see
405        // `www` itself — it usually has its own CNAME.
406        assert_eq!(
407            normalize_host("HTTPS://WWW.Example.com:443/path").unwrap(),
408            "www.example.com"
409        );
410        assert_eq!(normalize_host("www.com").unwrap(), "www.com");
411        // Everything else matches normalize_domain.
412        assert_eq!(
413            normalize_host("sub.example.com.").unwrap(),
414            "sub.example.com"
415        );
416        assert_eq!(normalize_host("münchen.de").unwrap(), "xn--mnchen-3ya.de");
417        assert!(normalize_host("nodots").is_err());
418        assert!(normalize_host("bad_label-.com").is_err());
419    }
420
421    #[test]
422    fn describe_reserved_ip_names_the_metadata_endpoint() {
423        // 169.254.169.254 is link-local too; the specific wording must win.
424        assert_eq!(
425            describe_reserved_ip(&"169.254.169.254".parse().unwrap()),
426            Some("cloud metadata endpoint (169.254.169.254)")
427        );
428        assert_eq!(
429            describe_reserved_ip(&"169.254.1.1".parse().unwrap()),
430            Some("link-local address (169.254.0.0/16)")
431        );
432    }
433
434    #[test]
435    fn test_normalize_idn_domain() {
436        // German: münchen.de -> xn--mnchen-3ya.de
437        let result = normalize_domain("münchen.de").unwrap();
438        assert_eq!(result, "xn--mnchen-3ya.de");
439
440        // Japanese: 例え.jp -> xn--r8jz45g.jp
441        let result = normalize_domain("例え.jp").unwrap();
442        assert_eq!(result, "xn--r8jz45g.jp");
443
444        // Chinese: 中文.com -> xn--fiq228c.com
445        let result = normalize_domain("中文.com").unwrap();
446        assert_eq!(result, "xn--fiq228c.com");
447
448        // With protocol prefix
449        let result = normalize_domain("https://münchen.de/path").unwrap();
450        assert_eq!(result, "xn--mnchen-3ya.de");
451
452        // Cyrillic, including uppercase input (lowercased before conversion)
453        assert_eq!(
454            normalize_domain("пример.рф").unwrap(),
455            "xn--e1afmkfd.xn--p1ai"
456        );
457        assert_eq!(
458            normalize_domain("ПРИМЕР.РФ").unwrap(),
459            "xn--e1afmkfd.xn--p1ai"
460        );
461
462        // Already-punycode input passes through unchanged
463        assert_eq!(
464            normalize_domain("xn--e1afmkfd.xn--p1ai").unwrap(),
465            "xn--e1afmkfd.xn--p1ai"
466        );
467    }
468
469    #[test]
470    fn test_allowlist_not_set_allows_all() {
471        // When SEER_DOMAIN_ALLOWLIST is not set, all domains pass
472        // This test verifies the default behavior (no env var)
473        assert!(normalize_domain("example.com").is_ok());
474        assert!(normalize_domain("example.xyz").is_ok());
475        assert!(normalize_domain("example.co.uk").is_ok());
476    }
477
478    #[test]
479    fn allowlist_suffix_matching() {
480        // #61: a full-domain entry must match the domain and its subdomains
481        // (the previous code only matched the final label, so "example.com"
482        // matched nothing), while a bare-TLD entry stays backward compatible.
483        let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
484        assert!(domain_matches_allowlist("example.com", &full));
485        assert!(domain_matches_allowlist("sub.example.com", &full));
486        assert!(
487            !domain_matches_allowlist("notexample.com", &full),
488            "label boundary"
489        );
490        assert!(!domain_matches_allowlist("example.org", &full));
491
492        let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
493        assert!(
494            domain_matches_allowlist("example.com", &tld),
495            "bare TLD still works"
496        );
497        assert!(domain_matches_allowlist("a.b.com", &tld));
498        assert!(domain_matches_allowlist("com", &tld));
499        assert!(!domain_matches_allowlist("example.org", &tld));
500        assert!(
501            !domain_matches_allowlist("scam", &tld),
502            "must not match mid-label"
503        );
504    }
505
506    #[test]
507    fn reserved_ip_check_blocks_private_ranges_only() {
508        for ip in [
509            "10.0.0.1",
510            "172.16.0.1",
511            "192.168.1.1",
512            "127.0.0.1",
513            "169.254.1.1",
514            "169.254.169.254",
515            "::1",
516            "fc00::1",
517            "fe80::1",
518        ] {
519            assert!(crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
520        }
521        for ip in ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"] {
522            assert!(!crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
523        }
524    }
525
526    #[test]
527    fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
528        // These were blocked by net::is_reserved_ip but NOT by the old
529        // validation checker that guards the RDAP + HTTP-redirect paths.
530        for ip in [
531            "100.64.0.1",
532            "198.18.0.1",
533            "192.0.0.1",
534            "0.1.2.3",
535            "240.0.0.1",
536        ] {
537            let addr: IpAddr = ip.parse().unwrap();
538            assert!(
539                describe_reserved_ip(&addr).is_some(),
540                "{ip} must be reported reserved"
541            );
542            assert!(crate::net::is_reserved_ip(addr), "{ip} bool check");
543        }
544        // IPv6 transition forms embedding the metadata IP (169.254.169.254).
545        for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
546            let addr: IpAddr = ip.parse().unwrap();
547            assert!(
548                describe_reserved_ip(&addr).is_some(),
549                "{ip} must be reported reserved"
550            );
551        }
552        // A genuinely public address is still allowed through.
553        assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
554    }
555}