Skip to main content

mur_common/
host_pattern.rs

1//! The single write-time validator for host allow/deny entries (#1905).
2//! Matching stays in `crate::net`; this only decides what may be STORED.
3
4use std::net::IpAddr;
5
6#[cfg(test)]
7mod tests;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NormalizedHost {
11    pub value: String,
12    pub notes: Vec<Normalization>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Normalization {
17    Lowercased { from: String },
18    Punycode { from: String },
19    LegacyDotWildcard,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum HostPatternError {
24    Empty,
25    Whitespace,
26    UrlOrPath,
27    HasPort,
28    NoDot,
29    BadChar(char),
30    BadLabel(String),
31    TooLong,
32    BadWildcard,
33}
34
35impl std::fmt::Display for HostPatternError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Empty => write!(f, "empty host"),
39            Self::Whitespace => write!(f, "contains a space — one host per argument"),
40            Self::UrlOrPath => write!(f, "contains a URL scheme or path — pass the bare host"),
41            Self::HasPort => write!(f, "contains a port — pass the bare host"),
42            Self::NoDot => write!(f, "has no dot — a host looks like `example.com`"),
43            Self::BadChar(c) => write!(f, "contains `{c}`, which no hostname can contain"),
44            Self::BadLabel(l) => write!(f, "label `{l}` is not a valid DNS label"),
45            Self::TooLong => write!(f, "longer than 253 characters"),
46            Self::BadWildcard => write!(
47                f,
48                "a wildcard is only allowed as the first label, e.g. `*.example.com`"
49            ),
50        }
51    }
52}
53
54const SLASHES: [char; 3] = ['/', '/', '\\'];
55
56pub fn normalize_host_pattern(raw: &str) -> Result<NormalizedHost, HostPatternError> {
57    let s = raw.trim();
58    if s.is_empty() {
59        return Err(HostPatternError::Empty);
60    }
61    if s.chars().any(char::is_whitespace) {
62        return Err(HostPatternError::Whitespace);
63    }
64    if s.contains("://") || s.contains(SLASHES) {
65        return Err(HostPatternError::UrlOrPath);
66    }
67    // IP literals first: bare IPv6 has colons that are not a port.
68    let unbracketed = s
69        .strip_prefix('[')
70        .and_then(|r| r.strip_suffix(']'))
71        .unwrap_or(s);
72    if let Ok(ip) = unbracketed.parse::<IpAddr>() {
73        // IPv6 is stored bracketed: both matchers compare exact strings, and
74        // both see `[v6]` — the egress proxy's CONNECT authority host and
75        // `Url::host_str`. A bare `::1` would never match (#1919 review).
76        let value = match ip {
77            IpAddr::V4(v4) => v4.to_string(),
78            IpAddr::V6(v6) => format!("[{v6}]"),
79        };
80        return Ok(NormalizedHost {
81            value,
82            notes: vec![],
83        });
84    }
85    if s.contains(':') {
86        return Err(HostPatternError::HasPort);
87    }
88    if s.eq_ignore_ascii_case("localhost") {
89        return Ok(NormalizedHost {
90            value: "localhost".into(),
91            notes: lower_note(s),
92        });
93    }
94    let mut notes = Vec::new();
95    let (prefix, name) = if let Some(rest) = s.strip_prefix("*.") {
96        ("*.", rest)
97    } else if let Some(rest) = s.strip_prefix('.') {
98        notes.push(Normalization::LegacyDotWildcard);
99        (".", rest)
100    } else {
101        ("", s)
102    };
103    if name.contains('*') {
104        return Err(HostPatternError::BadWildcard);
105    }
106    let ascii = if name.is_ascii() {
107        name.to_ascii_lowercase()
108    } else {
109        // Refuse punctuation/symbols before IDNA so `評分,` names the comma.
110        if let Some(c) = name
111            .chars()
112            .find(|c| !c.is_alphanumeric() && *c != '.' && *c != '-')
113        {
114            return Err(HostPatternError::BadChar(c));
115        }
116        let a = idna::domain_to_ascii_strict(name)
117            .map_err(|_| HostPatternError::BadLabel(name.into()))?;
118        notes.push(Normalization::Punycode { from: s.into() });
119        a
120    };
121    if name.is_ascii() && ascii != name {
122        notes.push(Normalization::Lowercased { from: s.into() });
123    }
124    if ascii.len() > 253 {
125        return Err(HostPatternError::TooLong);
126    }
127    let labels: Vec<&str> = ascii.split('.').collect();
128    if labels.len() < 2 {
129        return Err(HostPatternError::NoDot);
130    }
131    for l in &labels {
132        let ok = (1..=63).contains(&l.len())
133            && l.bytes()
134                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
135            && !l.starts_with('-')
136            && !l.ends_with('-');
137        if !ok {
138            return Err(HostPatternError::BadLabel((*l).into()));
139        }
140    }
141    Ok(NormalizedHost {
142        value: format!("{prefix}{ascii}"),
143        notes,
144    })
145}
146
147fn lower_note(s: &str) -> Vec<Normalization> {
148    if s.bytes().any(|b| b.is_ascii_uppercase()) {
149        vec![Normalization::Lowercased { from: s.into() }]
150    } else {
151        vec![]
152    }
153}