Skip to main content

postrust_proxy/saas/
wellknown_host.rs

1//! Host-header matching for the well-known challenge endpoints.
2//!
3//! Split out from the handler so it can be tested without a database or a
4//! running listener, which is most of what there is to get wrong here.
5
6use crate::saas::validation::normalize_domain;
7
8/// Whether a request's `Host` header names `domain`.
9///
10/// The comparison has to be deliberate rather than `==`, because a `Host`
11/// header is not a bare domain:
12///
13/// - it may carry a port (`example.com:8443`), which is not part of the name;
14/// - it is case-insensitive, per RFC 9110 section 4.2.3;
15/// - it may be an IPv6 literal in brackets (`[::1]:8443`), where the colons
16///   inside the brackets are not a port separator;
17/// - it may carry a fully-qualified trailing dot (`example.com.`), which names
18///   the same host as `example.com`.
19///
20/// Both sides go through [`normalize_domain`], the function every stored
21/// domain went through on the way in, so the comparison here cannot disagree
22/// with the uniqueness check about whether two spellings are one name. An IP
23/// literal never matches: no registered domain can be one.
24///
25/// Anything that does not parse as a host is not a match. Returning `false` on
26/// doubt is the safe direction: the caller uses this to decide whether to hand
27/// out a challenge secret.
28pub fn host_matches(host_header: Option<&str>, domain: &str) -> bool {
29    let Some(host) = host_header else {
30        return false;
31    };
32    let Some(name) = strip_port(host.trim()) else {
33        return false;
34    };
35    match (normalize_domain(name), normalize_domain(domain)) {
36        (Ok(name), Ok(domain)) => name == domain,
37        _ => false,
38    }
39}
40
41/// Drop a `:port` suffix, leaving the host name.
42///
43/// Returns `None` for input that is not a plausible host, including an
44/// unterminated IPv6 literal and a port that is not digits.
45fn strip_port(host: &str) -> Option<&str> {
46    if let Some(rest) = host.strip_prefix('[') {
47        // IPv6 literal: the colons before the closing bracket are part of the
48        // address, so the port can only follow the bracket.
49        let end = rest.find(']')?;
50        match rest[end + 1..].strip_prefix(':') {
51            // A bracketed address followed by anything that is not a port.
52            Some(port) if !is_port(port) => return None,
53            None if end + 1 != rest.len() => return None,
54            _ => {}
55        }
56        // Keep the brackets: that is how the authority form names the address,
57        // and how a configured domain would have to be written to match.
58        return Some(&host[..end + 2]);
59    }
60
61    match host.split_once(':') {
62        // A second colon outside brackets is not a valid authority.
63        Some((_, port)) if !is_port(port) => None,
64        Some((name, _)) => Some(name),
65        None => Some(host),
66    }
67}
68
69/// The digits of a port, with the `:` already removed. At least one, all ASCII.
70fn is_port(port: &str) -> bool {
71    !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn plain_match() {
80        assert!(host_matches(Some("example.com"), "example.com"));
81    }
82
83    #[test]
84    fn mismatch_is_refused() {
85        assert!(!host_matches(Some("evil.test"), "example.com"));
86    }
87
88    #[test]
89    fn missing_header_is_refused() {
90        assert!(!host_matches(None, "example.com"));
91    }
92
93    #[test]
94    fn port_is_not_part_of_the_name() {
95        assert!(host_matches(Some("example.com:8443"), "example.com"));
96        assert!(host_matches(Some("example.com:80"), "example.com"));
97    }
98
99    #[test]
100    fn comparison_is_case_insensitive() {
101        assert!(host_matches(Some("ExAmPlE.CoM"), "example.com"));
102        assert!(host_matches(Some("example.com"), "EXAMPLE.COM"));
103    }
104
105    #[test]
106    fn trailing_dot_names_the_same_host() {
107        assert!(host_matches(Some("example.com."), "example.com"));
108        assert!(host_matches(Some("example.com.:443"), "example.com"));
109    }
110
111    #[test]
112    fn ip_literals_never_match() {
113        // No registered domain can be an IP literal, so a Host that is one is
114        // never the host a challenge was issued for -- even if the stored
115        // value somehow were the same literal.
116        assert!(!host_matches(Some("[::1]:8443"), "[::1]"));
117        assert!(!host_matches(Some("[::1]"), "[::1]"));
118        assert!(!host_matches(Some("127.0.0.1"), "127.0.0.1"));
119    }
120
121    #[test]
122    fn a_case_variant_registration_is_the_same_name() {
123        // The finding: tenant B's `VICTIM.test` answering on `victim.test`.
124        // With both normalised the same way, the database refuses B's row
125        // and this agrees they are one name.
126        assert!(host_matches(Some("victim.test"), "VICTIM.test"));
127        assert!(host_matches(Some("VICTIM.TEST."), "victim.test"));
128    }
129
130    #[test]
131    fn unterminated_ipv6_literal_is_refused() {
132        assert!(!host_matches(Some("[::1:8443"), "[::1]"));
133    }
134
135    #[test]
136    fn non_numeric_port_is_refused() {
137        assert!(!host_matches(Some("example.com:https"), "example.com"));
138    }
139
140    #[test]
141    fn a_second_colon_is_refused() {
142        assert!(!host_matches(Some("example.com:80:80"), "example.com"));
143    }
144
145    #[test]
146    fn empty_inputs_are_refused() {
147        assert!(!host_matches(Some(""), "example.com"));
148        assert!(!host_matches(Some("example.com"), ""));
149        assert!(!host_matches(Some(":8443"), "example.com"));
150    }
151
152    #[test]
153    fn surrounding_whitespace_is_tolerated() {
154        assert!(host_matches(Some("  example.com  "), "example.com"));
155    }
156
157    #[test]
158    fn a_prefix_of_the_domain_is_not_a_match() {
159        // The bug this guards against is a `starts_with` implementation.
160        assert!(!host_matches(Some("example.com.evil.test"), "example.com"));
161        assert!(!host_matches(Some("notexample.com"), "example.com"));
162    }
163}