hns_browser_loopback_proxy/chromium/
host.rs1use 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 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}