1use anyhow::{Context, Result, bail, ensure};
10
11#[derive(Debug, PartialEq, Eq)]
13pub enum Target<'a> {
14 Alias { alias: &'a str, path: &'a str },
16 Index { path: &'a str },
22 Direct { path: &'a str },
24}
25
26pub fn classify<'a>(host: &'a str, path: &'a str, suffix: &str, port: u16) -> Result<Target<'a>> {
28 let (name, given_port) = split_host(host);
29
30 if let Some(alias) = name
31 .strip_suffix(suffix)
32 .and_then(|head| head.strip_suffix('.'))
33 {
34 ensure!(
35 is_label(alias),
36 "alias {alias:?} is not a bare hostname label"
37 );
38 ensure!(
41 matches!(given_port, None | Some(80) | Some(443)),
42 "refusing {host:?}: unexpected port for an alias"
43 );
44 return Ok(Target::Alias { alias, path });
45 }
46
47 if name == suffix {
50 ensure!(
51 matches!(given_port, None | Some(80) | Some(443)),
52 "refusing {host:?}: unexpected port for the index"
53 );
54 return Ok(Target::Index { path });
55 }
56
57 if matches!(name, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
58 ensure!(
62 given_port == Some(port),
63 "refusing {host:?}: not this listener's port {port}"
64 );
65 return Ok(Target::Direct { path });
66 }
67
68 bail!("refusing Host {host:?}: neither <alias>.{suffix} nor this loopback listener")
69}
70
71fn split_host(host: &str) -> (&str, Option<u16>) {
72 if let Some(rest) = host.strip_prefix('[') {
74 return match rest.split_once("]:") {
75 Some((addr, port)) => (&host[..addr.len() + 2], port.parse().ok()),
76 None => (host, None),
77 };
78 }
79 match host.rsplit_once(':') {
80 Some((name, port)) => (name, port.parse().ok()),
81 None => (host, None),
82 }
83}
84
85pub(crate) fn is_label(s: &str) -> bool {
92 !s.is_empty()
93 && !s.starts_with('-')
94 && !s.ends_with('-')
95 && s.bytes()
96 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
97}
98
99pub fn resolve(base: &str, path: &str) -> Result<String> {
106 let decoded = percent_decode(path)?;
107 ensure!(!decoded.contains('\0'), "path contains NUL");
108
109 let mut out: Vec<&str> = Vec::new();
110 for segment in decoded.split('/') {
111 match segment {
112 "" | "." => {}
113 ".." => {
114 if out.pop().is_none() {
115 bail!("path escapes the alias base");
116 }
117 }
118 s => out.push(s),
119 }
120 }
121
122 let base = base.trim_end_matches('/');
123 if out.is_empty() {
124 return Ok(base.to_string());
125 }
126 Ok(format!("{base}/{}", out.join("/")))
127}
128
129fn percent_decode(s: &str) -> Result<String> {
130 let b = s.as_bytes();
131 let mut out = Vec::with_capacity(b.len());
132 let mut i = 0;
133 while i < b.len() {
134 if b[i] == b'%' {
135 let hi = *b.get(i + 1).context("truncated percent escape")?;
136 let lo = *b.get(i + 2).context("truncated percent escape")?;
137 out.push((hex(hi)? << 4) | hex(lo)?);
138 i += 3;
139 } else {
140 out.push(b[i]);
141 i += 1;
142 }
143 }
144 String::from_utf8(out).context("path is not valid UTF-8 once decoded")
145}
146
147fn hex(c: u8) -> Result<u8> {
148 match c {
149 b'0'..=b'9' => Ok(c - b'0'),
150 b'a'..=b'f' => Ok(c - b'a' + 10),
151 b'A'..=b'F' => Ok(c - b'A' + 10),
152 _ => bail!("bad hex digit in percent escape"),
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
167 fn the_suffix_on_its_own_is_the_index() {
168 assert_eq!(
169 classify("ssh-browser", "/", "ssh-browser", 7391).unwrap(),
170 Target::Index { path: "/" }
171 );
172 assert_eq!(
174 classify("ssh-browser:80", "/", "ssh-browser", 7391).unwrap(),
175 Target::Index { path: "/" }
176 );
177 assert_eq!(
178 classify("ssh-browser:443", "/", "ssh-browser", 7391).unwrap(),
179 Target::Index { path: "/" }
180 );
181 assert!(classify("ssh-browser:9999", "/", "ssh-browser", 7391).is_err());
183 }
184
185 #[test]
190 fn a_lookalike_is_not_the_index() {
191 assert!(classify("ssh-browser.evil.example", "/", "ssh-browser", 7391).is_err());
192 assert!(classify("notssh-browser", "/", "ssh-browser", 7391).is_err());
193 assert!(classify("evil.example", "/", "ssh-browser", 7391).is_err());
194 }
195
196 #[test]
197 fn an_alias_host_is_recognised() {
198 assert_eq!(
199 classify("docs.ssh-browser", "/docs/", "ssh-browser", 7391).unwrap(),
200 Target::Alias {
201 alias: "docs",
202 path: "/docs/"
203 }
204 );
205 }
206
207 #[test]
208 fn the_loopback_listener_is_recognised_on_its_own_port() {
209 assert_eq!(
210 classify("127.0.0.1:7391", "/proxy.pac", "ssh-browser", 7391).unwrap(),
211 Target::Direct { path: "/proxy.pac" }
212 );
213 }
214
215 #[test]
218 fn a_rebinding_host_is_refused() {
219 assert!(classify("evil.example", "/", "ssh-browser", 7391).is_err());
220 assert!(classify("127.0.0.1:9999", "/", "ssh-browser", 7391).is_err());
221 assert!(classify("docs.ssh-browser.evil.example", "/", "ssh-browser", 7391).is_err());
222 }
223
224 #[test]
225 fn an_alias_must_be_a_bare_label() {
226 assert!(classify("a.b.ssh-browser", "/", "ssh-browser", 7391).is_err());
227 assert!(classify("-bad.ssh-browser", "/", "ssh-browser", 7391).is_err());
228 assert!(classify(".ssh-browser", "/", "ssh-browser", 7391).is_err());
229 }
230
231 #[test]
232 fn paths_resolve_under_the_base() {
233 assert_eq!(
234 resolve("/srv/docs", "/a/b.html").unwrap(),
235 "/srv/docs/a/b.html"
236 );
237 assert_eq!(resolve("/srv/docs/", "/").unwrap(), "/srv/docs");
238 assert_eq!(resolve("/srv/docs", "/a/./b").unwrap(), "/srv/docs/a/b");
239 assert_eq!(resolve("/srv/docs", "/a/../b").unwrap(), "/srv/docs/b");
240 }
241
242 #[test]
243 fn traversal_is_refused_however_it_is_spelled() {
244 assert!(resolve("/srv/docs", "/../etc/passwd").is_err());
245 assert!(resolve("/srv/docs", "/a/../../etc/passwd").is_err());
246 assert!(resolve("/srv/docs", "/%2e%2e/etc/passwd").is_err());
249 assert!(resolve("/srv/docs", "/%2E%2E%2Fetc/passwd").is_err());
250 }
251
252 #[test]
253 fn a_nul_byte_is_refused() {
254 assert!(resolve("/srv/docs", "/a%00b").is_err());
255 }
256}