Skip to main content

seer_core/
validation.rs

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