ssh_browser/origin/
guard.rs1use anyhow::{Context, Result, bail, ensure};
10
11#[derive(Debug, PartialEq, Eq)]
13pub enum Target<'a> {
14 Alias { alias: &'a str, path: &'a str },
16 Direct { path: &'a str },
18}
19
20pub fn classify<'a>(host: &'a str, path: &'a str, suffix: &str, port: u16) -> Result<Target<'a>> {
22 let (name, given_port) = split_host(host);
23
24 if let Some(alias) = name
25 .strip_suffix(suffix)
26 .and_then(|head| head.strip_suffix('.'))
27 {
28 ensure!(
29 is_label(alias),
30 "alias {alias:?} is not a bare hostname label"
31 );
32 ensure!(
35 matches!(given_port, None | Some(80) | Some(443)),
36 "refusing {host:?}: unexpected port for an alias"
37 );
38 return Ok(Target::Alias { alias, path });
39 }
40
41 if matches!(name, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
42 ensure!(
46 given_port == Some(port),
47 "refusing {host:?}: not this listener's port {port}"
48 );
49 return Ok(Target::Direct { path });
50 }
51
52 bail!("refusing Host {host:?}: neither <alias>.{suffix} nor this loopback listener")
53}
54
55fn split_host(host: &str) -> (&str, Option<u16>) {
56 if let Some(rest) = host.strip_prefix('[') {
58 return match rest.split_once("]:") {
59 Some((addr, port)) => (&host[..addr.len() + 2], port.parse().ok()),
60 None => (host, None),
61 };
62 }
63 match host.rsplit_once(':') {
64 Some((name, port)) => (name, port.parse().ok()),
65 None => (host, None),
66 }
67}
68
69pub(crate) fn is_label(s: &str) -> bool {
76 !s.is_empty()
77 && !s.starts_with('-')
78 && !s.ends_with('-')
79 && s.bytes()
80 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
81}
82
83pub fn resolve(base: &str, path: &str) -> Result<String> {
90 let decoded = percent_decode(path)?;
91 ensure!(!decoded.contains('\0'), "path contains NUL");
92
93 let mut out: Vec<&str> = Vec::new();
94 for segment in decoded.split('/') {
95 match segment {
96 "" | "." => {}
97 ".." => {
98 if out.pop().is_none() {
99 bail!("path escapes the alias base");
100 }
101 }
102 s => out.push(s),
103 }
104 }
105
106 let base = base.trim_end_matches('/');
107 if out.is_empty() {
108 return Ok(base.to_string());
109 }
110 Ok(format!("{base}/{}", out.join("/")))
111}
112
113fn percent_decode(s: &str) -> Result<String> {
114 let b = s.as_bytes();
115 let mut out = Vec::with_capacity(b.len());
116 let mut i = 0;
117 while i < b.len() {
118 if b[i] == b'%' {
119 let hi = *b.get(i + 1).context("truncated percent escape")?;
120 let lo = *b.get(i + 2).context("truncated percent escape")?;
121 out.push((hex(hi)? << 4) | hex(lo)?);
122 i += 3;
123 } else {
124 out.push(b[i]);
125 i += 1;
126 }
127 }
128 String::from_utf8(out).context("path is not valid UTF-8 once decoded")
129}
130
131fn hex(c: u8) -> Result<u8> {
132 match c {
133 b'0'..=b'9' => Ok(c - b'0'),
134 b'a'..=b'f' => Ok(c - b'a' + 10),
135 b'A'..=b'F' => Ok(c - b'A' + 10),
136 _ => bail!("bad hex digit in percent escape"),
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn an_alias_host_is_recognised() {
146 assert_eq!(
147 classify("docs.ssh-browser", "/docs/", "ssh-browser", 7391).unwrap(),
148 Target::Alias {
149 alias: "docs",
150 path: "/docs/"
151 }
152 );
153 }
154
155 #[test]
156 fn the_loopback_listener_is_recognised_on_its_own_port() {
157 assert_eq!(
158 classify("127.0.0.1:7391", "/proxy.pac", "ssh-browser", 7391).unwrap(),
159 Target::Direct { path: "/proxy.pac" }
160 );
161 }
162
163 #[test]
166 fn a_rebinding_host_is_refused() {
167 assert!(classify("evil.example", "/", "ssh-browser", 7391).is_err());
168 assert!(classify("127.0.0.1:9999", "/", "ssh-browser", 7391).is_err());
169 assert!(classify("docs.ssh-browser.evil.example", "/", "ssh-browser", 7391).is_err());
170 }
171
172 #[test]
173 fn an_alias_must_be_a_bare_label() {
174 assert!(classify("a.b.ssh-browser", "/", "ssh-browser", 7391).is_err());
175 assert!(classify("-bad.ssh-browser", "/", "ssh-browser", 7391).is_err());
176 assert!(classify(".ssh-browser", "/", "ssh-browser", 7391).is_err());
177 }
178
179 #[test]
180 fn paths_resolve_under_the_base() {
181 assert_eq!(
182 resolve("/srv/docs", "/a/b.html").unwrap(),
183 "/srv/docs/a/b.html"
184 );
185 assert_eq!(resolve("/srv/docs/", "/").unwrap(), "/srv/docs");
186 assert_eq!(resolve("/srv/docs", "/a/./b").unwrap(), "/srv/docs/a/b");
187 assert_eq!(resolve("/srv/docs", "/a/../b").unwrap(), "/srv/docs/b");
188 }
189
190 #[test]
191 fn traversal_is_refused_however_it_is_spelled() {
192 assert!(resolve("/srv/docs", "/../etc/passwd").is_err());
193 assert!(resolve("/srv/docs", "/a/../../etc/passwd").is_err());
194 assert!(resolve("/srv/docs", "/%2e%2e/etc/passwd").is_err());
197 assert!(resolve("/srv/docs", "/%2E%2E%2Fetc/passwd").is_err());
198 }
199
200 #[test]
201 fn a_nul_byte_is_refused() {
202 assert!(resolve("/srv/docs", "/a%00b").is_err());
203 }
204}