Skip to main content

sbe_proxy/
allowlist.rs

1/// Domain allowlist for the proxy.
2///
3/// Supports exact matches and wildcard prefix patterns (`*.example.com`).
4#[derive(Debug, Clone)]
5pub struct DomainAllowlist {
6    patterns: Vec<AllowlistEntry>,
7}
8
9#[derive(Debug, Clone)]
10enum AllowlistEntry {
11    Exact(String),
12    Wildcard(String), // stores the suffix (e.g., "example.com" for "*.example.com")
13}
14
15impl DomainAllowlist {
16    /// Create a new allowlist from domain pattern strings.
17    ///
18    /// Patterns can be exact (`"registry.npmjs.org"`) or wildcard (`"*.npmjs.org"`).
19    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    /// Check whether a domain is allowed.
36    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    /// Check if the allowlist is empty (no domains allowed).
49    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}