use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::LazyLock;
use crate::error::{Result, SeerError};
static DOMAIN_ALLOWLIST: LazyLock<Option<HashSet<String>>> = LazyLock::new(|| {
let set: HashSet<String> = std::env::var("SEER_DOMAIN_ALLOWLIST")
.ok()?
.split(',')
.map(normalize_allowlist_entry)
.filter(|s| !s.is_empty())
.collect();
if set.is_empty() {
None
} else {
Some(set)
}
});
fn normalize_allowlist_entry(entry: &str) -> String {
let lowered = entry.trim().to_lowercase();
if lowered.is_ascii() {
lowered
} else {
domain_to_ascii(&lowered).unwrap_or(lowered)
}
}
pub fn normalize_domain(domain: &str) -> Result<String> {
normalize(domain, NameKind::Domain)
}
pub fn normalize_host(host: &str) -> Result<String> {
normalize(host, NameKind::Host)
}
pub fn normalize_query_name(name: &str) -> Result<String> {
normalize(name, NameKind::QueryName)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NameKind {
Domain,
Host,
QueryName,
}
fn normalize(domain: &str, kind: NameKind) -> Result<String> {
let domain = domain
.trim()
.to_lowercase()
.replace(['\u{3002}', '\u{FF0E}', '\u{FF61}'], ".");
let domain = domain
.strip_prefix("http://")
.or_else(|| domain.strip_prefix("https://"))
.unwrap_or(&domain);
let domain = domain.split('/').next().unwrap_or(domain);
let domain = domain.split('?').next().unwrap_or(domain);
let domain = domain.split('#').next().unwrap_or(domain);
let domain = domain.rsplit('@').next().unwrap_or(domain);
let domain = match domain.rsplit_once(':') {
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
_ => domain,
};
let absolute = domain.ends_with('.');
let domain = domain.strip_suffix('.').unwrap_or(domain);
let domain = match domain.strip_prefix("www.") {
Some(rest) if kind == NameKind::Domain && rest.contains('.') => rest,
_ => domain,
};
let single_label_ok = kind == NameKind::QueryName && absolute;
if domain.is_empty() || (!domain.contains('.') && !single_label_ok) {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
let domain = if !domain.is_ascii() {
domain_to_ascii(domain)?
} else {
domain.to_string()
};
let labels = match domain.strip_prefix("*.") {
Some(rest) if kind == NameKind::QueryName => rest,
_ => domain.as_str(),
};
let valid = labels
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
if !valid {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
if labels.contains("..") || labels.starts_with('.') || labels.ends_with('.') {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
if domain.len() > 253 {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
for label in labels.split('.') {
if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
if label.len() > 63 {
return Err(SeerError::InvalidDomain(domain.to_string()));
}
}
if let Some(ref allowlist) = *DOMAIN_ALLOWLIST {
if !domain_matches_allowlist(&domain, allowlist) {
let tld = domain.rsplit('.').next().unwrap_or(&domain).to_string();
return Err(SeerError::DomainNotAllowed {
domain: domain.to_string(),
tld,
});
}
}
Ok(domain.to_string())
}
fn domain_matches_allowlist(domain: &str, allowlist: &HashSet<String>) -> bool {
allowlist.iter().any(|entry| {
if domain == entry.as_str() {
return true;
}
domain.len() > entry.len()
&& domain.ends_with(entry.as_str())
&& domain.as_bytes()[domain.len() - entry.len() - 1] == b'.'
})
}
pub(crate) fn domain_to_ascii(domain: &str) -> Result<String> {
idna::domain_to_ascii(domain).map_err(|_| {
SeerError::InvalidDomain(format!("invalid internationalized domain: {}", domain))
})
}
pub fn describe_reserved_ip(ip: &IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => {
if v4.is_unspecified() {
return Some("unspecified address (0.0.0.0) — domain has no routable IP");
}
if v4.is_loopback() {
return Some("loopback address (127.0.0.0/8)");
}
if v4.is_private() {
return Some("private network (RFC 1918)");
}
let o = v4.octets();
if o[0] == 169 && o[1] == 254 && o[2] == 169 && o[3] == 254 {
return Some("cloud metadata endpoint (169.254.169.254)");
}
if v4.is_link_local() {
return Some("link-local address (169.254.0.0/16)");
}
if (o[0] == 192 && o[1] == 0 && o[2] == 2)
|| (o[0] == 198 && o[1] == 51 && o[2] == 100)
|| (o[0] == 203 && o[1] == 0 && o[2] == 113)
{
return Some("documentation/test range (RFC 5737)");
}
if v4.is_broadcast() {
return Some("broadcast address (255.255.255.255)");
}
if o[0] >= 224 && o[0] <= 239 {
return Some("multicast address (224.0.0.0/4)");
}
if o[0] >= 240 {
return Some("reserved address (240.0.0.0/4)");
}
if crate::net::is_reserved_ip(IpAddr::V4(*v4)) {
return Some("reserved/special-purpose address range");
}
None
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
return Some("IPv6 loopback (::1)");
}
if v6.is_unspecified() {
return Some("IPv6 unspecified address (::) — domain has no routable IP");
}
let seg = v6.segments();
if (seg[0] & 0xfe00) == 0xfc00 {
return Some("IPv6 unique local address (fc00::/7)");
}
if (seg[0] & 0xffc0) == 0xfe80 {
return Some("IPv6 link-local address (fe80::/10)");
}
if (seg[0] & 0xffc0) == 0xfec0 {
return Some("IPv6 site-local address (fec0::/10)");
}
if seg[0] >> 8 == 0xff {
return Some("IPv6 multicast (ff00::/8)");
}
if let Some(v4) = v6.to_ipv4_mapped() {
if crate::net::is_reserved_ip(IpAddr::V4(v4)) {
return Some("IPv4-mapped IPv6 address in private/reserved range");
}
}
if crate::net::is_reserved_ip(IpAddr::V6(*v6)) {
return Some("reserved/special-purpose IPv6 range");
}
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allowlist_entry_idn_is_punycoded() {
assert_eq!(normalize_allowlist_entry("münchen.de"), "xn--mnchen-3ya.de");
assert_eq!(normalize_allowlist_entry(" Example.COM "), "example.com");
}
#[test]
fn allowlist_matches_idn_after_normalization() {
let mut set = HashSet::new();
set.insert(normalize_allowlist_entry("münchen.de"));
assert!(domain_matches_allowlist("xn--mnchen-3ya.de", &set));
}
#[test]
fn test_normalize_domain() {
assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
assert_eq!(
normalize_domain("https://www.example.com/path").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("http://example.com/").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain(" WWW.EXAMPLE.COM ").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("example.com?query=1").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("example.com#section").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("https://example.com/path?q=1#frag").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("_dmarc.example.com").unwrap(),
"_dmarc.example.com"
);
assert_eq!(
normalize_domain("selector1._domainkey.example.com").unwrap(),
"selector1._domainkey.example.com"
);
assert_eq!(
normalize_domain("_sip._tcp.example.com").unwrap(),
"_sip._tcp.example.com"
);
assert!(normalize_domain("").is_err());
assert!(normalize_domain("nodots").is_err());
assert!(normalize_domain("example..com").is_err());
assert!(normalize_domain(".example.com").is_err());
assert!(normalize_domain("-example.com").is_err());
assert!(normalize_domain("example-.com").is_err());
assert_eq!(
normalize_domain("https://example.com:8443/admin").unwrap(),
"example.com"
);
assert_eq!(normalize_domain("example.com:443").unwrap(), "example.com");
assert_eq!(
normalize_domain("https://user@example.com/").unwrap(),
"example.com"
);
assert_eq!(
normalize_domain("https://user:pass@example.com:8443/path").unwrap(),
"example.com"
);
assert_eq!(normalize_domain("example.com.").unwrap(), "example.com");
assert_eq!(
normalize_domain("https://example.com.").unwrap(),
"example.com"
);
assert!(normalize_domain("example.com..").is_err());
}
#[test]
fn www_is_not_stripped_when_it_would_leave_a_bare_tld() {
assert_eq!(normalize_domain("www.com").unwrap(), "www.com");
assert_eq!(normalize_domain("WWW.NET.").unwrap(), "www.net");
assert_eq!(normalize_domain("https://www.io/x").unwrap(), "www.io");
assert_eq!(normalize_domain("www.example.com.").unwrap(), "example.com");
}
#[test]
fn normalize_host_keeps_www() {
assert_eq!(
normalize_host("HTTPS://WWW.Example.com:443/path").unwrap(),
"www.example.com"
);
assert_eq!(normalize_host("www.com").unwrap(), "www.com");
assert_eq!(
normalize_host("sub.example.com.").unwrap(),
"sub.example.com"
);
assert_eq!(normalize_host("münchen.de").unwrap(), "xn--mnchen-3ya.de");
assert!(normalize_host("nodots").is_err());
assert!(normalize_host("bad_label-.com").is_err());
}
#[test]
fn normalize_query_name_accepts_a_leading_wildcard_label() {
assert_eq!(
normalize_query_name("*.Example.COM.").unwrap(),
"*.example.com"
);
assert_eq!(
normalize_query_name("*.www.example.com").unwrap(),
"*.www.example.com"
);
assert_eq!(normalize_query_name("*.com").unwrap(), "*.com");
assert_eq!(
normalize_query_name("*.münchen.de").unwrap(),
"*.xn--mnchen-3ya.de"
);
assert_eq!(
normalize_query_name("https://WWW.Example.com:443/x").unwrap(),
"www.example.com"
);
}
#[test]
fn normalize_query_name_rejects_a_misplaced_or_partial_wildcard() {
for bad in [
"*",
"*.",
"**.example.com",
"*a.example.com",
"a*.example.com",
"a.*.example.com",
"*.*.example.com",
"*..example.com",
"*.-bad.example.com",
] {
assert!(
normalize_query_name(bad).is_err(),
"{bad:?} must be rejected"
);
}
}
#[test]
fn host_and_domain_normalizers_still_reject_wildcards() {
assert!(normalize_host("*.example.com").is_err());
assert!(normalize_domain("*.example.com").is_err());
}
#[test]
fn an_absolute_single_label_is_a_query_name_only() {
assert_eq!(normalize_query_name("com.").unwrap(), "com");
assert_eq!(normalize_query_name("MX.").unwrap(), "mx");
assert!(normalize_query_name("com").is_err());
assert!(normalize_host("com.").is_err());
assert!(normalize_domain("com.").is_err());
assert!(normalize_query_name(".").is_err());
}
#[test]
fn describe_reserved_ip_names_the_metadata_endpoint() {
assert_eq!(
describe_reserved_ip(&"169.254.169.254".parse().unwrap()),
Some("cloud metadata endpoint (169.254.169.254)")
);
assert_eq!(
describe_reserved_ip(&"169.254.1.1".parse().unwrap()),
Some("link-local address (169.254.0.0/16)")
);
}
#[test]
fn idna_full_stops_separate_labels() {
for input in ["例え\u{3002}jp", "例え\u{FF0E}jp", "例え\u{FF61}jp"] {
assert_eq!(
normalize_domain(input).unwrap(),
"xn--r8jz45g.jp",
"{input:?}"
);
}
assert_eq!(
normalize_host("www\u{3002}example\u{3002}com").unwrap(),
"www.example.com"
);
}
#[test]
fn test_normalize_idn_domain() {
let result = normalize_domain("münchen.de").unwrap();
assert_eq!(result, "xn--mnchen-3ya.de");
let result = normalize_domain("例え.jp").unwrap();
assert_eq!(result, "xn--r8jz45g.jp");
let result = normalize_domain("中文.com").unwrap();
assert_eq!(result, "xn--fiq228c.com");
let result = normalize_domain("https://münchen.de/path").unwrap();
assert_eq!(result, "xn--mnchen-3ya.de");
assert_eq!(
normalize_domain("пример.рф").unwrap(),
"xn--e1afmkfd.xn--p1ai"
);
assert_eq!(
normalize_domain("ПРИМЕР.РФ").unwrap(),
"xn--e1afmkfd.xn--p1ai"
);
assert_eq!(
normalize_domain("xn--e1afmkfd.xn--p1ai").unwrap(),
"xn--e1afmkfd.xn--p1ai"
);
}
#[test]
fn test_allowlist_not_set_allows_all() {
assert!(normalize_domain("example.com").is_ok());
assert!(normalize_domain("example.xyz").is_ok());
assert!(normalize_domain("example.co.uk").is_ok());
}
#[test]
fn allowlist_suffix_matching() {
let full: HashSet<String> = ["example.com".to_string()].into_iter().collect();
assert!(domain_matches_allowlist("example.com", &full));
assert!(domain_matches_allowlist("sub.example.com", &full));
assert!(
!domain_matches_allowlist("notexample.com", &full),
"label boundary"
);
assert!(!domain_matches_allowlist("example.org", &full));
let tld: HashSet<String> = ["com".to_string()].into_iter().collect();
assert!(
domain_matches_allowlist("example.com", &tld),
"bare TLD still works"
);
assert!(domain_matches_allowlist("a.b.com", &tld));
assert!(domain_matches_allowlist("com", &tld));
assert!(!domain_matches_allowlist("example.org", &tld));
assert!(
!domain_matches_allowlist("scam", &tld),
"must not match mid-label"
);
}
#[test]
fn reserved_ip_check_blocks_private_ranges_only() {
for ip in [
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"127.0.0.1",
"169.254.1.1",
"169.254.169.254",
"::1",
"fc00::1",
"fe80::1",
] {
assert!(crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
}
for ip in ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"] {
assert!(!crate::net::is_reserved_ip(ip.parse().unwrap()), "{ip}");
}
}
#[test]
fn describe_reserved_ip_agrees_with_net_on_previously_divergent_ranges() {
for ip in [
"100.64.0.1",
"198.18.0.1",
"192.0.0.1",
"0.1.2.3",
"240.0.0.1",
] {
let addr: IpAddr = ip.parse().unwrap();
assert!(
describe_reserved_ip(&addr).is_some(),
"{ip} must be reported reserved"
);
assert!(crate::net::is_reserved_ip(addr), "{ip} bool check");
}
for ip in ["64:ff9b::a9fe:a9fe", "2002:a9fe:a9fe::", "::a9fe:a9fe"] {
let addr: IpAddr = ip.parse().unwrap();
assert!(
describe_reserved_ip(&addr).is_some(),
"{ip} must be reported reserved"
);
}
assert!(describe_reserved_ip(&"8.8.8.8".parse().unwrap()).is_none());
}
}