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
6/// Whether a request's `Host` header names `domain`.
7///
8/// The comparison has to be deliberate rather than `==`, because a `Host`
9/// header is not a bare domain:
10///
11/// - it may carry a port (`example.com:8443`), which is not part of the name;
12/// - it is case-insensitive, per RFC 9110 section 4.2.3;
13/// - it may be an IPv6 literal in brackets (`[::1]:8443`), where the colons
14///   inside the brackets are not a port separator;
15/// - it may carry a fully-qualified trailing dot (`example.com.`), which names
16///   the same host as `example.com`.
17///
18/// Anything that does not parse as a host is not a match. Returning `false` on
19/// doubt is the safe direction: the caller uses this to decide whether to hand
20/// out a challenge secret.
21pub fn host_matches(host_header: Option<&str>, domain: &str) -> bool {
22    let Some(host) = host_header else {
23        return false;
24    };
25    let Some(name) = strip_port(host.trim()) else {
26        return false;
27    };
28    if name.is_empty() || domain.is_empty() {
29        return false;
30    }
31    normalize(name) == normalize(domain)
32}
33
34/// Drop a `:port` suffix, leaving the host name.
35///
36/// Returns `None` for input that is not a plausible host, including an
37/// unterminated IPv6 literal and a port that is not digits.
38fn strip_port(host: &str) -> Option<&str> {
39    if let Some(rest) = host.strip_prefix('[') {
40        // IPv6 literal: the colons before the closing bracket are part of the
41        // address, so the port can only follow the bracket.
42        let end = rest.find(']')?;
43        match rest[end + 1..].strip_prefix(':') {
44            // A bracketed address followed by anything that is not a port.
45            Some(port) if !is_port(port) => return None,
46            None if end + 1 != rest.len() => return None,
47            _ => {}
48        }
49        // Keep the brackets: that is how the authority form names the address,
50        // and how a configured domain would have to be written to match.
51        return Some(&host[..end + 2]);
52    }
53
54    match host.split_once(':') {
55        // A second colon outside brackets is not a valid authority.
56        Some((_, port)) if !is_port(port) => None,
57        Some((name, _)) => Some(name),
58        None => Some(host),
59    }
60}
61
62/// The digits of a port, with the `:` already removed. At least one, all ASCII.
63fn is_port(port: &str) -> bool {
64    !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())
65}
66
67/// Lowercase, and drop one fully-qualified trailing dot.
68fn normalize(name: &str) -> String {
69    name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn plain_match() {
78        assert!(host_matches(Some("example.com"), "example.com"));
79    }
80
81    #[test]
82    fn mismatch_is_refused() {
83        assert!(!host_matches(Some("evil.test"), "example.com"));
84    }
85
86    #[test]
87    fn missing_header_is_refused() {
88        assert!(!host_matches(None, "example.com"));
89    }
90
91    #[test]
92    fn port_is_not_part_of_the_name() {
93        assert!(host_matches(Some("example.com:8443"), "example.com"));
94        assert!(host_matches(Some("example.com:80"), "example.com"));
95    }
96
97    #[test]
98    fn comparison_is_case_insensitive() {
99        assert!(host_matches(Some("ExAmPlE.CoM"), "example.com"));
100        assert!(host_matches(Some("example.com"), "EXAMPLE.COM"));
101    }
102
103    #[test]
104    fn trailing_dot_names_the_same_host() {
105        assert!(host_matches(Some("example.com."), "example.com"));
106        assert!(host_matches(Some("example.com.:443"), "example.com"));
107    }
108
109    #[test]
110    fn ipv6_literal_keeps_its_brackets() {
111        assert!(host_matches(Some("[::1]:8443"), "[::1]"));
112        assert!(host_matches(Some("[::1]"), "[::1]"));
113    }
114
115    #[test]
116    fn unterminated_ipv6_literal_is_refused() {
117        assert!(!host_matches(Some("[::1:8443"), "[::1]"));
118    }
119
120    #[test]
121    fn non_numeric_port_is_refused() {
122        assert!(!host_matches(Some("example.com:https"), "example.com"));
123    }
124
125    #[test]
126    fn a_second_colon_is_refused() {
127        assert!(!host_matches(Some("example.com:80:80"), "example.com"));
128    }
129
130    #[test]
131    fn empty_inputs_are_refused() {
132        assert!(!host_matches(Some(""), "example.com"));
133        assert!(!host_matches(Some("example.com"), ""));
134        assert!(!host_matches(Some(":8443"), "example.com"));
135    }
136
137    #[test]
138    fn surrounding_whitespace_is_tolerated() {
139        assert!(host_matches(Some("  example.com  "), "example.com"));
140    }
141
142    #[test]
143    fn a_prefix_of_the_domain_is_not_a_match() {
144        // The bug this guards against is a `starts_with` implementation.
145        assert!(!host_matches(Some("example.com.evil.test"), "example.com"));
146        assert!(!host_matches(Some("notexample.com"), "example.com"));
147    }
148}