1#[derive(Debug, Clone)]
5pub struct DomainAllowlist {
6 patterns: Vec<AllowlistEntry>,
7}
8
9#[derive(Debug, Clone)]
10enum AllowlistEntry {
11 Exact(String),
12 Wildcard(String), }
14
15impl DomainAllowlist {
16 pub fn new(patterns: &[String]) -> Result<Self, String> {
20 let patterns = patterns
21 .iter()
22 .map(|pattern| {
23 let pattern = pattern.trim().trim_end_matches('.');
24 if let Some(suffix) = pattern.strip_prefix("*.") {
25 let suffix = canonical_hostname(suffix)?;
26 Ok(AllowlistEntry::Wildcard(suffix))
27 } else {
28 Ok(AllowlistEntry::Exact(canonical_hostname(pattern)?))
29 }
30 })
31 .collect::<Result<Vec<_>, String>>()?;
32 Ok(Self { patterns })
33 }
34
35 pub fn is_allowed(&self, domain: &str) -> bool {
37 let Ok(domain) = canonical_hostname(domain.trim_end_matches('.')) else {
38 return false;
39 };
40 self.patterns.iter().any(|entry| match entry {
41 AllowlistEntry::Exact(d) => domain == *d,
42 AllowlistEntry::Wildcard(suffix) => {
43 domain == *suffix || domain.ends_with(&format!(".{suffix}"))
44 }
45 })
46 }
47
48 pub fn is_empty(&self) -> bool {
50 self.patterns.is_empty()
51 }
52}
53
54fn canonical_hostname(host: &str) -> Result<String, String> {
55 let host = idna::domain_to_ascii(host)
56 .map_err(|_| format!("invalid IDNA hostname '{host}'"))?
57 .to_ascii_lowercase();
58 if host.is_empty() || host.len() > 253 || host.parse::<std::net::IpAddr>().is_ok() {
59 return Err(format!("invalid hostname '{host}'"));
60 }
61 for label in host.split('.') {
62 if label.is_empty()
63 || label.len() > 63
64 || label.starts_with('-')
65 || label.ends_with('-')
66 || !label
67 .bytes()
68 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
69 {
70 return Err(format!("invalid hostname '{host}'"));
71 }
72 }
73 Ok(host)
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn test_should_allow_exact_match() {
82 let al = DomainAllowlist::new(&["registry.npmjs.org".to_owned()]).unwrap();
83 assert!(al.is_allowed("registry.npmjs.org"));
84 assert!(!al.is_allowed("evil.com"));
85 }
86
87 #[test]
88 fn test_should_allow_wildcard_match() {
89 let al = DomainAllowlist::new(&["*.npmjs.org".to_owned()]).unwrap();
90 assert!(al.is_allowed("registry.npmjs.org"));
91 assert!(al.is_allowed("npmjs.org"));
92 assert!(!al.is_allowed("evil.com"));
93 }
94
95 #[test]
96 fn test_should_be_case_insensitive() {
97 let al = DomainAllowlist::new(&["Registry.NPMJS.org".to_owned()]).unwrap();
98 assert!(al.is_allowed("registry.npmjs.org"));
99 assert!(al.is_allowed("REGISTRY.NPMJS.ORG"));
100 }
101
102 #[test]
103 fn test_should_handle_empty_allowlist() {
104 let al = DomainAllowlist::new(&[]).unwrap();
105 assert!(!al.is_allowed("anything.com"));
106 assert!(al.is_empty());
107 }
108}