Skip to main content

rama_http/matcher/
domain.rs

1use crate::Request;
2
3use rama_core::{extensions::Extensions, telemetry::tracing};
4use rama_net::AuthorityInputExt;
5use rama_net::address::{Domain, IntoDomain};
6
7#[derive(Debug, Clone)]
8/// Matcher based on the (sub)domain of the request's URI.
9pub struct DomainMatcher {
10    domain: Domain,
11    sub: bool,
12}
13
14impl DomainMatcher {
15    /// create a new domain matcher to match on an exact URI host match.
16    ///
17    /// If the host is an Ip it will not match.
18    #[must_use]
19    pub fn exact(domain: impl IntoDomain) -> Self {
20        Self {
21            domain: domain.into_domain(),
22            sub: false,
23        }
24    }
25    /// create a new domain matcher to match on a subdomain of the URI host match.
26    ///
27    /// Note that a domain is also a subdomain of itself, so this will also
28    /// include all matches that [`Self::exact`] would capture.
29    #[must_use]
30    pub fn sub(domain: impl IntoDomain) -> Self {
31        Self {
32            domain: domain.into_domain(),
33            sub: true,
34        }
35    }
36}
37
38impl<Body> rama_core::matcher::Matcher<Request<Body>> for DomainMatcher {
39    fn matches(&self, _: Option<&Extensions>, req: &Request<Body>) -> bool {
40        let Some(authority) = req.authority() else {
41            tracing::error!("DomainMatcher: failed to resolve authority");
42            return false;
43        };
44        let host = authority.host;
45
46        // IP-first: pct-encoded IP literals (`%31%32%37.0.0.1`) promote
47        // to both Domain (the digits-and-dots form passes the shallow
48        // Domain validator) AND IpAddr. The Domain match would be wrong
49        // for IP hosts. Filter them out first.
50        if host.try_as_ip().is_ok() {
51            tracing::trace!("DomainMatcher: host is an IP — no match");
52            return false;
53        }
54        // Pct-encoded reg-names that decode to a domain get matched too.
55        // Non-promotable hosts (sub-delim reg-name, IPvFuture) never match.
56        let Ok(domain) = host.try_into_domain() else {
57            tracing::trace!("DomainMatcher: host is not a domain — no match");
58            return false;
59        };
60        if self.sub {
61            tracing::trace!("DomainMatcher: ({}).is_parent_of({})", self.domain, domain);
62            self.domain.is_parent_of(&domain)
63        } else {
64            tracing::trace!("DomainMatcher: ({}) == ({})", self.domain, domain);
65            self.domain == domain
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::Request;
74    use rama_core::matcher::Matcher as _;
75
76    fn req_with_host(host_header: &str) -> Request<()> {
77        // Build with a path-only URI and set the Host header — that's
78        // the lane authority resolution uses for the lookup, and
79        // unlike `http::Uri` it accepts pct-encoded reg-names there.
80        Request::builder()
81            .uri("/")
82            .header("host", host_header)
83            .body(())
84            .unwrap()
85    }
86
87    #[test]
88    fn plain_domain_matches() {
89        let m = DomainMatcher::exact(rama_net::address::Domain::from_static("example.com"));
90        assert!(m.matches(None, &req_with_host("example.com")));
91    }
92
93    #[test]
94    fn pct_encoded_reg_name_matches_via_bridge() {
95        // `exa%6Dple.com` pct-decodes to `example.com` — bridge takes
96        // us from `Host::Uninterpreted` to `Domain` for matching.
97        let m = DomainMatcher::exact(rama_net::address::Domain::from_static("example.com"));
98        assert!(m.matches(None, &req_with_host("exa%6Dple.com")));
99    }
100
101    #[test]
102    fn ip_host_does_not_match_domain() {
103        // IP-first: an IP literal must not match a domain matcher,
104        // even though the shallow Domain validator accepts it.
105        let m = DomainMatcher::exact(rama_net::address::Domain::from_static("127.0.0.1"));
106        assert!(!m.matches(None, &req_with_host("127.0.0.1")));
107    }
108
109    #[test]
110    fn pct_encoded_ip_does_not_match_domain() {
111        // Regression: `%31%32%37.0.0.1` pct-decodes to `127.0.0.1`,
112        // which both Domain and IpAddr promotion accept. The IP-first
113        // filter must catch this before the domain match runs.
114        let m = DomainMatcher::exact(rama_net::address::Domain::from_static("127.0.0.1"));
115        assert!(!m.matches(None, &req_with_host("%31%32%37.0.0.1")));
116    }
117
118    #[test]
119    fn subdomain_match() {
120        let m = DomainMatcher::sub(rama_net::address::Domain::from_static("example.com"));
121        assert!(m.matches(None, &req_with_host("api.example.com")));
122        assert!(m.matches(None, &req_with_host("example.com")));
123        assert!(!m.matches(None, &req_with_host("other.example")));
124    }
125}