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(|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
30/// Normalizes and validates a domain name.
31///
32/// This function:
33/// - Removes http:// and https:// prefixes
34/// - Removes www. prefix
35/// - Removes trailing slashes and paths
36/// - Converts to lowercase
37/// - Converts internationalized domain names (IDN) to Punycode (ASCII)
38/// - Validates format (must contain dots, only alphanumeric/hyphens/dots)
39/// - Does NOT perform SSRF checks. For network operations, resolve and
40///   validate via `crate::net::resolve_public_host` (or `validate_public_host`),
41///   which returns the vetted `SocketAddr`s to connect to — closing the
42///   resolve-then-connect (DNS-rebinding) window.
43pub fn normalize_domain(domain: &str) -> Result<String> {
44    let domain = domain.trim().to_lowercase();
45
46    // Remove protocol
47    let domain = domain
48        .strip_prefix("http://")
49        .or_else(|| domain.strip_prefix("https://"))
50        .unwrap_or(&domain);
51
52    // Remove trailing slash, path, query parameters, and fragments
53    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    // Strip userinfo (`user:pass@host`) — take the host portion after the
58    // last '@'. This runs after path stripping so a stray '@' in a path
59    // segment can't affect the host.
60    let domain = domain.rsplit('@').next().unwrap_or(domain);
61
62    // Strip a trailing port (`host:8443`) but only when it is `:` followed
63    // entirely by digits, so we never truncate anything else (and IPv6
64    // literals, which are not valid here anyway, won't match this shape).
65    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    // Remove www. prefix
71    let domain = domain.strip_prefix("www.").unwrap_or(domain);
72
73    // Strip a single trailing dot (FQDN form: `example.com.` → `example.com`).
74    // DNS libraries and copy-paste from `dig` output routinely include the
75    // root-label dot; rejecting it would force callers to pre-clean inputs
76    // that are otherwise valid.
77    let domain = domain.strip_suffix('.').unwrap_or(domain);
78
79    // Validate domain format
80    if domain.is_empty() || !domain.contains('.') {
81        return Err(SeerError::InvalidDomain(domain.to_string()));
82    }
83
84    // Convert internationalized domain names (IDN) to ASCII/Punycode
85    let domain = if !domain.is_ascii() {
86        domain_to_ascii(domain)?
87    } else {
88        domain.to_string()
89    };
90
91    // Basic validation - alphanumeric, hyphens, dots, and underscores
92    // Underscores are valid in DNS names (RFC 8552) and required for service
93    // records like _dmarc., _domainkey., _sip._tcp., etc.
94    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    // Check for consecutive dots or dots at start/end
102    if domain.contains("..") || domain.starts_with('.') || domain.ends_with('.') {
103        return Err(SeerError::InvalidDomain(domain.to_string()));
104    }
105
106    // RFC 1035: total domain name length ≤ 253 characters
107    if domain.len() > 253 {
108        return Err(SeerError::InvalidDomain(domain.to_string()));
109    }
110
111    // Check label constraints
112    for label in domain.split('.') {
113        // Labels must be non-empty and not start/end with hyphens
114        if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
115            return Err(SeerError::InvalidDomain(domain.to_string()));
116        }
117        // RFC 1035: each label ≤ 63 characters
118        if label.len() > 63 {
119            return Err(SeerError::InvalidDomain(domain.to_string()));
120        }
121    }
122
123    // Check against the allowlist (if configured) using suffix matching.
124    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
137/// Returns true if `domain` is permitted by `allowlist` using label-boundary
138/// suffix matching: an entry matches the domain itself or any subdomain of it.
139///
140/// This fixes the misleading control where `SEER_DOMAIN_ALLOWLIST` only compared
141/// the final label, so a full-domain entry like `example.com` matched nothing
142/// (issue #61). A bare-TLD entry (e.g. `com`) still matches all `*.com` domains,
143/// so existing TLD-style configs keep working; a full-domain entry (e.g.
144/// `example.com`) now matches `example.com` and `sub.example.com` as the name
145/// implies. Both `domain` and the entries are already lowercased.
146fn 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        // Subdomain match: `domain` ends with ".<entry>" on a label boundary.
152        domain.len() > entry.len()
153            && domain.ends_with(entry.as_str())
154            && domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
155    })
156}
157
158/// Converts an internationalized domain name to ASCII (Punycode).
159fn 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
165/// Checks if an IP address is in a private or reserved range.
166///
167/// Delegates to [`crate::net::is_reserved_ip`] — the single source of truth for
168/// SSRF range checks across every outbound leg (RDAP, WHOIS, status, DNS) — so
169/// the policy can never drift between call sites. See that function for the full
170/// range list (RFC1918, loopback, link-local + metadata, CGNAT, IETF
171/// 192.0.0.0/24, benchmark, documentation, 0.0.0.0/8, class-E, and the IPv6
172/// ULA / link-local / documentation / 6to4 / NAT64 / IPv4-mapped & -compatible
173/// forms).
174pub fn is_private_or_reserved_ip(ip: &IpAddr) -> bool {
175    crate::net::is_reserved_ip(*ip)
176}
177
178/// Checks if an IPv4 address is private or reserved.
179///
180/// Thin wrapper over [`crate::net::is_reserved_ip`] (kept for the
181/// `describe_reserved_ip` reason logic); the canonical range list lives there.
182fn is_private_or_reserved_ipv4(ip: &Ipv4Addr) -> bool {
183    crate::net::is_reserved_ip(IpAddr::V4(*ip))
184}
185
186/// Returns a human-readable reason why an IP is blocked, or `None` if it is
187/// safe.  Intended for error messages — callers should still use
188/// [`is_private_or_reserved_ip`] for the fast boolean check.
189pub 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            // Catch-all: any range the canonical checker blocks but for which we
227            // have no specific wording (CGNAT 100.64/10, IETF 192.0.0.0/24,
228            // benchmark 198.18/15, 0.0.0.0/8, …) is still refused — never
229            // under-block relative to net::is_reserved_ip.
230            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            // Catch-all for 6to4 / NAT64 / IPv4-compatible / documentation and
258            // any other range the canonical checker blocks.
259            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        // Query parameters and fragments
290        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        // Underscore domains (DNS service records)
304        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        // Invalid domains
318        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        // Port and userinfo are stripped from the host.
326        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        // FQDN form is accepted: single trailing dot is stripped.
341        assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
342        assert_eq!(
343            normalize_domain("https://example.com.").unwrap(),
344            "example.com"
345        );
346        // Double trailing dot is still invalid (would leave a trailing dot
347        // after stripping just one).
348        assert!(normalize_domain("example.com..").is_err());
349    }
350
351    #[test]
352    fn test_normalize_idn_domain() {
353        // German: münchen.de -> xn--mnchen-3ya.de
354        let result = normalize_domain("münchen.de").unwrap();
355        assert_eq!(result, "xn--mnchen-3ya.de");
356
357        // Japanese: 例え.jp -> xn--r8jz45g.jp
358        let result = normalize_domain("例え.jp").unwrap();
359        assert_eq!(result, "xn--r8jz45g.jp");
360
361        // Chinese: 中文.com -> xn--fiq228c.com
362        let result = normalize_domain("中文.com").unwrap();
363        assert_eq!(result, "xn--fiq228c.com");
364
365        // With protocol prefix
366        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        // When SEER_DOMAIN_ALLOWLIST is not set, all domains pass
373        // This test verifies the default behavior (no env var)
374        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        // #61: a full-domain entry must match the domain and its subdomains
382        // (the previous code only matched the final label, so "example.com"
383        // matched nothing), while a bare-TLD entry stays backward compatible.
384        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        // Private networks
410        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        // Loopback
421        assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
422            127, 0, 0, 1
423        ))));
424
425        // Link-local
426        assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
427            169, 254, 1, 1
428        ))));
429
430        // Cloud metadata
431        assert!(is_private_or_reserved_ip(&IpAddr::V4(Ipv4Addr::new(
432            169, 254, 169, 254
433        ))));
434
435        // Public IP (should not be blocked)
436        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        // Loopback
447        assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
448            0, 0, 0, 0, 0, 0, 0, 1
449        ))));
450
451        // Unique local
452        assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
453            0xfc00, 0, 0, 0, 0, 0, 0, 1
454        ))));
455
456        // Link-local
457        assert!(is_private_or_reserved_ip(&IpAddr::V6(Ipv6Addr::new(
458            0xfe80, 0, 0, 0, 0, 0, 0, 1
459        ))));
460
461        // Public IPv6 (should not be blocked)
462        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        // These were blocked by net::is_reserved_ip but NOT by the old
470        // validation checker that guards the RDAP + HTTP-redirect paths.
471        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        // IPv6 transition forms embedding the metadata IP (169.254.169.254).
486        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        // A genuinely public address is still allowed through.
494        assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
495    }
496}