Skip to main content

hns_browser_loopback_proxy/chromium/
host.rs

1use std::net::IpAddr;
2use std::str::FromStr;
3
4use hns_resolver::{NameClass, classify_name};
5use thiserror::Error;
6
7#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct NormalizedHost {
9    ascii: String,
10}
11
12impl NormalizedHost {
13    pub fn parse(input: &str) -> Result<Self, HostNormalizationError> {
14        if input.is_empty() {
15            return Err(HostNormalizationError::Empty);
16        }
17        if input.trim() != input {
18            return Err(HostNormalizationError::SurroundingWhitespace);
19        }
20        if input.chars().any(is_forbidden_input_character) {
21            return Err(HostNormalizationError::ForbiddenCharacter);
22        }
23
24        let input = strip_one_terminal_dot(input);
25        if input.is_empty() || input.chars().last().is_some_and(is_idna_dot) {
26            return Err(HostNormalizationError::InvalidDnsName);
27        }
28        let ascii = idna::domain_to_ascii_cow(input.as_bytes(), idna::AsciiDenyList::URL)
29            .map_err(|_| HostNormalizationError::InvalidIdna)?
30            .to_ascii_lowercase();
31        validate_ascii_dns_name(&ascii)?;
32        if ascii.parse::<IpAddr>().is_ok() || looks_like_legacy_ipv4(&ascii) {
33            return Err(HostNormalizationError::IpLiteral);
34        }
35
36        Ok(Self { ascii })
37    }
38
39    pub fn as_str(&self) -> &str {
40        &self.ascii
41    }
42
43    pub fn class(&self) -> NameClass {
44        classify_name(&self.ascii)
45    }
46
47    pub fn is_hns(&self) -> bool {
48        self.class() == NameClass::Hns
49    }
50}
51
52impl AsRef<str> for NormalizedHost {
53    fn as_ref(&self) -> &str {
54        self.as_str()
55    }
56}
57
58impl std::ops::Deref for NormalizedHost {
59    type Target = str;
60
61    fn deref(&self) -> &Self::Target {
62        self.as_str()
63    }
64}
65
66impl std::fmt::Debug for NormalizedHost {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        formatter.write_str("NormalizedHost([REDACTED])")
69    }
70}
71
72impl std::fmt::Display for NormalizedHost {
73    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        formatter.write_str(&self.ascii)
75    }
76}
77
78impl FromStr for NormalizedHost {
79    type Err = HostNormalizationError;
80
81    fn from_str(input: &str) -> Result<Self, Self::Err> {
82        Self::parse(input)
83    }
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct HostScope {
88    root: NormalizedHost,
89}
90
91impl HostScope {
92    pub fn new(root: impl AsRef<str>) -> Result<Self, HostScopeError> {
93        let root = NormalizedHost::parse(root.as_ref())?;
94        ensure_hns(&root)?;
95        Ok(Self { root })
96    }
97
98    pub fn root(&self) -> &NormalizedHost {
99        &self.root
100    }
101
102    pub fn allows(&self, candidate: &str) -> bool {
103        self.authorize(candidate).is_ok()
104    }
105
106    pub fn allows_normalized(&self, candidate: &NormalizedHost) -> bool {
107        candidate.is_hns() && is_equal_or_subdomain(candidate.as_str(), self.root.as_str())
108    }
109
110    /// Returns the canonical target only when it is an HNS name inside this immutable scope.
111    pub fn authorize(&self, candidate: &str) -> Result<NormalizedHost, HostScopeError> {
112        let candidate = NormalizedHost::parse(candidate)?;
113        ensure_hns(&candidate)?;
114        if !self.allows_normalized(&candidate) {
115            return Err(HostScopeError::OutOfScope);
116        }
117        Ok(candidate)
118    }
119}
120
121#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
122pub enum HostNormalizationError {
123    #[error("host is empty")]
124    Empty,
125    #[error("host contains surrounding whitespace")]
126    SurroundingWhitespace,
127    #[error("host contains a forbidden authority character")]
128    ForbiddenCharacter,
129    #[error("host is not valid strict IDNA")]
130    InvalidIdna,
131    #[error("host violates DNS name length or label rules")]
132    InvalidDnsName,
133    #[error("IP literals are outside the HNS proxy boundary")]
134    IpLiteral,
135}
136
137#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
138pub enum HostScopeError {
139    #[error(transparent)]
140    InvalidHost(#[from] HostNormalizationError),
141    #[error("host is not classified in the HNS namespace")]
142    NotHns,
143    #[error("host is outside the immutable HNS proxy scope")]
144    OutOfScope,
145}
146
147fn ensure_hns(host: &NormalizedHost) -> Result<(), HostScopeError> {
148    if host.is_hns() {
149        Ok(())
150    } else {
151        Err(HostScopeError::NotHns)
152    }
153}
154
155fn is_equal_or_subdomain(candidate: &str, root: &str) -> bool {
156    if candidate == root {
157        return true;
158    }
159    let Some(prefix) = candidate.strip_suffix(root) else {
160        return false;
161    };
162    prefix.ends_with('.')
163}
164
165fn is_forbidden_input_character(character: char) -> bool {
166    character.is_control()
167        || character.is_whitespace()
168        || matches!(
169            character,
170            '/' | ':' | '?' | '#' | '@' | '[' | ']' | '\\' | '<' | '>' | '"'
171        )
172}
173
174fn strip_one_terminal_dot(input: &str) -> &str {
175    input
176        .char_indices()
177        .next_back()
178        .filter(|(_, character)| is_idna_dot(*character))
179        .map_or(input, |(index, _)| &input[..index])
180}
181
182fn is_idna_dot(character: char) -> bool {
183    matches!(character, '.' | '\u{3002}' | '\u{ff0e}' | '\u{ff61}')
184}
185
186fn validate_ascii_dns_name(host: &str) -> Result<(), HostNormalizationError> {
187    if host.is_empty() || host.len() > 253 || host.ends_with('.') {
188        return Err(HostNormalizationError::InvalidDnsName);
189    }
190    if host.split('.').any(|label| {
191        label.is_empty()
192            || label.len() > 63
193            || label.starts_with('-')
194            || label.ends_with('-')
195            || !label
196                .bytes()
197                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
198    }) {
199        return Err(HostNormalizationError::InvalidDnsName);
200    }
201    Ok(())
202}
203
204fn looks_like_legacy_ipv4(host: &str) -> bool {
205    let mut labels = host.split('.');
206    let mut count = 0usize;
207    let all_numeric = labels.all(|label| {
208        count += 1;
209        is_ipv4_number(label)
210    });
211    all_numeric && (1..=4).contains(&count)
212}
213
214fn is_ipv4_number(label: &str) -> bool {
215    if let Some(hex) = label
216        .strip_prefix("0x")
217        .or_else(|| label.strip_prefix("0X"))
218    {
219        return !hex.is_empty() && hex.bytes().all(|byte| byte.is_ascii_hexdigit());
220    }
221    if label.len() > 1 && label.starts_with('0') {
222        return label.bytes().all(|byte| matches!(byte, b'0'..=b'7'));
223    }
224    !label.is_empty() && label.bytes().all(|byte| byte.is_ascii_digit())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn canonicalizes_case_trailing_dot_and_unicode_with_strict_idna() {
233        assert_eq!(
234            NormalizedHost::parse("WELCOME.").unwrap().as_str(),
235            "welcome"
236        );
237        assert_eq!(
238            NormalizedHost::parse("BÜCHER").unwrap().as_str(),
239            "xn--bcher-kva"
240        );
241        assert_eq!(
242            NormalizedHost::parse("café.WELCOME").unwrap().as_str(),
243            "xn--caf-dma.welcome"
244        );
245        assert_eq!(NormalizedHost::parse("🤝").unwrap().as_str(), "xn--5p9h");
246        assert_eq!(
247            NormalizedHost::parse("welcome。").unwrap().as_str(),
248            "welcome"
249        );
250    }
251
252    #[test]
253    fn rejects_authorities_search_input_ip_literals_and_invalid_dns_names() {
254        for invalid in [
255            "",
256            " welcome",
257            "welcome ",
258            "two words",
259            "https://welcome",
260            "welcome:443",
261            "user@welcome",
262            "welcome/path",
263            "welcome..",
264            ".welcome",
265            "-welcome",
266            "welcome-",
267        ] {
268            assert!(NormalizedHost::parse(invalid).is_err(), "{invalid}");
269        }
270        assert_eq!(
271            NormalizedHost::parse("127.0.0.1"),
272            Err(HostNormalizationError::IpLiteral)
273        );
274        for legacy_ip in ["2130706433", "0x7f000001", "127.1", "0177.0.0.1"] {
275            assert_eq!(
276                NormalizedHost::parse(legacy_ip),
277                Err(HostNormalizationError::IpLiteral),
278                "{legacy_ip}"
279            );
280        }
281        assert!(NormalizedHost::parse("[::1]").is_err());
282    }
283
284    #[test]
285    fn debug_output_does_not_retain_the_browsing_scope() {
286        let host = NormalizedHost::parse("private-history-name").unwrap();
287        let scope = HostScope::new(host.as_str()).unwrap();
288
289        assert!(!format!("{host:?}").contains(host.as_str()));
290        assert!(!format!("{scope:?}").contains(host.as_str()));
291    }
292
293    #[test]
294    fn classification_is_delegated_to_the_resolver_policy() {
295        assert_eq!(
296            NormalizedHost::parse("welcome").unwrap().class(),
297            NameClass::Hns
298        );
299        assert_eq!(
300            NormalizedHost::parse("www.welcome").unwrap().class(),
301            NameClass::Hns
302        );
303        assert_eq!(
304            NormalizedHost::parse("example.com").unwrap().class(),
305            NameClass::Icann
306        );
307        assert_eq!(
308            NormalizedHost::parse("service.localhost").unwrap().class(),
309            NameClass::Icann
310        );
311    }
312
313    #[test]
314    fn scope_accepts_only_the_exact_root_and_label_boundary_subdomains() {
315        let scope = HostScope::new("Welcome.").unwrap();
316
317        assert!(scope.allows("welcome"));
318        assert!(scope.allows("www.welcome"));
319        assert!(scope.allows("deep.www.welcome."));
320        assert!(!scope.allows("evilwelcome"));
321        assert!(!scope.allows("welcome.evil"));
322        assert!(!scope.allows("example.com"));
323    }
324
325    #[test]
326    fn scope_compares_idna_canonical_forms() {
327        let scope = HostScope::new("Bücher").unwrap();
328
329        assert!(scope.allows("xn--bcher-kva"));
330        assert!(scope.allows("shop.BÜCHER."));
331    }
332
333    #[test]
334    fn scope_construction_fails_closed_for_non_hns_names() {
335        assert_eq!(HostScope::new("example.com"), Err(HostScopeError::NotHns));
336        assert_eq!(HostScope::new("localhost"), Err(HostScopeError::NotHns));
337        assert_eq!(
338            HostScope::new("127.0.0.1"),
339            Err(HostScopeError::InvalidHost(
340                HostNormalizationError::IpLiteral
341            ))
342        );
343    }
344
345    #[test]
346    fn authorize_preserves_invalid_out_of_namespace_and_out_of_scope_failures() {
347        let scope = HostScope::new("welcome").unwrap();
348
349        assert_eq!(
350            scope.authorize(" welcome"),
351            Err(HostScopeError::InvalidHost(
352                HostNormalizationError::SurroundingWhitespace
353            ))
354        );
355        assert_eq!(scope.authorize("example.com"), Err(HostScopeError::NotHns));
356        assert_eq!(scope.authorize("other"), Err(HostScopeError::OutOfScope));
357    }
358}