Skip to main content

rama_http/matcher/
subdomain_trie.rs

1use crate::Request;
2use rama_core::telemetry::tracing;
3use rama_core::{extensions::Extensions, matcher::Matcher};
4use rama_net::AuthorityInputExt;
5use rama_net::address::{AsDomainRef, DomainTrie};
6
7#[derive(Debug, Clone)]
8/// A matcher that matches subdomains.
9///
10/// Note that a domain is considered also a suddomain of itself.
11pub struct SubdomainTrieMatcher {
12    trie: DomainTrie<()>,
13}
14
15impl SubdomainTrieMatcher {
16    /// Create a new [`SubdomainTrieMatcher`].
17    ///
18    /// Every input domain is registered as a **subtree** entry — it matches
19    /// the apex itself plus every descendant. Inputs already in wildcard
20    /// form (`"*.foo.bar"`) pass through; bare inputs (`"foo.bar"`) are
21    /// promoted to `*.foo.bar` first. Inputs that can't be made into a
22    /// valid wildcard (e.g. exceeding the length cap) are silently skipped.
23    pub fn new<I, S>(domains: I) -> Self
24    where
25        I: IntoIterator<Item = S>,
26        S: AsDomainRef,
27    {
28        let mut trie = DomainTrie::new();
29        for d in domains {
30            if let Ok(w) = d.to_wildcard() {
31                trie.insert_domain(w, ());
32            }
33        }
34        Self { trie }
35    }
36
37    // Returns true if a domain is a subdomain of any domain lineage in this [`SubdomainTrieMatcher`].
38    pub fn is_match(&self, domain: impl AsDomainRef) -> bool {
39        self.trie.is_match(domain)
40    }
41}
42
43impl<Body> Matcher<Request<Body>> for SubdomainTrieMatcher {
44    fn matches(&self, _: Option<&Extensions>, req: &Request<Body>) -> bool {
45        let Some(authority) = req.authority() else {
46            tracing::debug!("SubdomainTrieMatcher: failed to resolve authority");
47            return false;
48        };
49
50        // IP-first: pct-encoded IP literals (`%31%32%37.0.0.1`) can
51        // promote to both Domain and IpAddr (shallow Domain validator
52        // accepts digits-and-dots). The Domain match would be wrong for
53        // IP hosts. Filter them out first.
54        if authority.host.try_as_ip().is_ok() {
55            tracing::trace!("SubdomainTrieMatcher: host is an IP — no match");
56            return false;
57        }
58        // Pct-encoded reg-names that decode to a domain participate.
59        // Non-promotable hosts (sub-delim, IPvFuture) don't.
60        let Ok(domain) = authority.host.try_as_domain() else {
61            tracing::trace!("SubdomainTrieMatcher: host is not a domain — no match");
62            return false;
63        };
64        let is_match = self.is_match(domain.as_ref());
65        tracing::trace!(
66            "SubdomainTrieMatcher: matching domain = {}, matched = {}",
67            domain,
68            is_match
69        );
70        is_match
71    }
72}
73
74impl<S> FromIterator<S> for SubdomainTrieMatcher
75where
76    S: AsDomainRef,
77{
78    #[inline]
79    fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
80        Self::new(iter)
81    }
82}
83
84#[cfg(test)]
85mod subdomain_trie_tests {
86    use super::*;
87    use rama_net::uri::Uri;
88
89    #[test]
90    fn test_trie_matching() {
91        let matcher = SubdomainTrieMatcher::new(vec!["example.com", "sub.domain.org"]);
92        assert!(matcher.is_match("example.com"));
93        assert!(matcher.is_match(".example.com"));
94        assert!(matcher.is_match("sub.domain.org"));
95        assert!(matcher.is_match("sub.example.com"));
96        assert!(!matcher.is_match("domain.org"));
97        assert!(!matcher.is_match("other.com"));
98        assert!(!matcher.is_match("localhost"));
99    }
100
101    #[test]
102    fn test_path_matching_with_trie() {
103        let domains = ["example.com", "sub.domain.org"];
104        let matcher: SubdomainTrieMatcher = domains.into_iter().collect();
105
106        let path = "sub.example.com";
107
108        let request = Request::builder()
109            .uri(Uri::parse_authority_form(path).unwrap())
110            .body(())
111            .unwrap();
112        assert!(matcher.matches(None, &request));
113    }
114
115    #[test]
116    fn test_non_matching_path() {
117        let domains = ["example.com"];
118        let matcher: SubdomainTrieMatcher = domains.into_iter().collect();
119
120        let path = "nonmatching.com";
121
122        let request = Request::builder()
123            .uri(Uri::parse_authority_form(path).unwrap())
124            .body(())
125            .unwrap();
126        assert!(!matcher.matches(None, &request));
127    }
128
129    fn req_with_host(host_header: &str) -> Request<()> {
130        Request::builder()
131            .uri("/")
132            .header("host", host_header)
133            .body(())
134            .unwrap()
135    }
136
137    #[test]
138    fn pct_encoded_reg_name_matches_via_bridge() {
139        // `exa%6Dple.com` pct-decodes to `example.com` — the subdomain
140        // trie includes a `sub.example.com` style entry which should
141        // match the bridged domain.
142        let matcher: SubdomainTrieMatcher = ["example.com"].into_iter().collect();
143        assert!(matcher.matches(None, &req_with_host("exa%6Dple.com")));
144    }
145
146    #[test]
147    fn ip_host_does_not_match() {
148        let matcher: SubdomainTrieMatcher = ["127.0.0.1"].into_iter().collect();
149        assert!(!matcher.matches(None, &req_with_host("127.0.0.1")));
150        // Regression: pct-encoded IP that bridges to a digits-and-dots
151        // string the shallow Domain validator accepts. Must be filtered
152        // out by the IP-first check.
153        assert!(!matcher.matches(None, &req_with_host("%31%32%37.0.0.1")));
154    }
155}