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