use anyhow::{Context, Result, bail, ensure};
#[derive(Debug, PartialEq, Eq)]
pub enum Target<'a> {
Alias { alias: &'a str, path: &'a str },
Index { path: &'a str },
Direct { path: &'a str },
}
pub fn classify<'a>(host: &'a str, path: &'a str, suffix: &str, port: u16) -> Result<Target<'a>> {
let (name, given_port) = split_host(host);
if let Some(alias) = name
.strip_suffix(suffix)
.and_then(|head| head.strip_suffix('.'))
{
ensure!(
is_label(alias),
"alias {alias:?} is not a bare hostname label"
);
ensure!(
matches!(given_port, None | Some(80) | Some(443)),
"refusing {host:?}: unexpected port for an alias"
);
return Ok(Target::Alias { alias, path });
}
if name == suffix {
ensure!(
matches!(given_port, None | Some(80) | Some(443)),
"refusing {host:?}: unexpected port for the index"
);
return Ok(Target::Index { path });
}
if matches!(name, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
ensure!(
given_port == Some(port),
"refusing {host:?}: not this listener's port {port}"
);
return Ok(Target::Direct { path });
}
bail!("refusing Host {host:?}: neither <alias>.{suffix} nor this loopback listener")
}
fn split_host(host: &str) -> (&str, Option<u16>) {
if let Some(rest) = host.strip_prefix('[') {
return match rest.split_once("]:") {
Some((addr, port)) => (&host[..addr.len() + 2], port.parse().ok()),
None => (host, None),
};
}
match host.rsplit_once(':') {
Some((name, port)) => (name, port.parse().ok()),
None => (host, None),
}
}
pub(crate) fn is_label(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('-')
&& !s.ends_with('-')
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub fn resolve(base: &str, path: &str) -> Result<String> {
let decoded = percent_decode(path)?;
ensure!(!decoded.contains('\0'), "path contains NUL");
let mut out: Vec<&str> = Vec::new();
for segment in decoded.split('/') {
match segment {
"" | "." => {}
".." => {
if out.pop().is_none() {
bail!("path escapes the alias base");
}
}
s => out.push(s),
}
}
let base = base.trim_end_matches('/');
if out.is_empty() {
return Ok(base.to_string());
}
Ok(format!("{base}/{}", out.join("/")))
}
fn percent_decode(s: &str) -> Result<String> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let hi = *b.get(i + 1).context("truncated percent escape")?;
let lo = *b.get(i + 2).context("truncated percent escape")?;
out.push((hex(hi)? << 4) | hex(lo)?);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).context("path is not valid UTF-8 once decoded")
}
fn hex(c: u8) -> Result<u8> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => bail!("bad hex digit in percent escape"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_suffix_on_its_own_is_the_index() {
assert_eq!(
classify("ssh-browser", "/", "ssh-browser", 7391).unwrap(),
Target::Index { path: "/" }
);
assert_eq!(
classify("ssh-browser:80", "/", "ssh-browser", 7391).unwrap(),
Target::Index { path: "/" }
);
assert_eq!(
classify("ssh-browser:443", "/", "ssh-browser", 7391).unwrap(),
Target::Index { path: "/" }
);
assert!(classify("ssh-browser:9999", "/", "ssh-browser", 7391).is_err());
}
#[test]
fn a_lookalike_is_not_the_index() {
assert!(classify("ssh-browser.evil.example", "/", "ssh-browser", 7391).is_err());
assert!(classify("notssh-browser", "/", "ssh-browser", 7391).is_err());
assert!(classify("evil.example", "/", "ssh-browser", 7391).is_err());
}
#[test]
fn an_alias_host_is_recognised() {
assert_eq!(
classify("docs.ssh-browser", "/docs/", "ssh-browser", 7391).unwrap(),
Target::Alias {
alias: "docs",
path: "/docs/"
}
);
}
#[test]
fn the_loopback_listener_is_recognised_on_its_own_port() {
assert_eq!(
classify("127.0.0.1:7391", "/proxy.pac", "ssh-browser", 7391).unwrap(),
Target::Direct { path: "/proxy.pac" }
);
}
#[test]
fn a_rebinding_host_is_refused() {
assert!(classify("evil.example", "/", "ssh-browser", 7391).is_err());
assert!(classify("127.0.0.1:9999", "/", "ssh-browser", 7391).is_err());
assert!(classify("docs.ssh-browser.evil.example", "/", "ssh-browser", 7391).is_err());
}
#[test]
fn an_alias_must_be_a_bare_label() {
assert!(classify("a.b.ssh-browser", "/", "ssh-browser", 7391).is_err());
assert!(classify("-bad.ssh-browser", "/", "ssh-browser", 7391).is_err());
assert!(classify(".ssh-browser", "/", "ssh-browser", 7391).is_err());
}
#[test]
fn paths_resolve_under_the_base() {
assert_eq!(
resolve("/srv/docs", "/a/b.html").unwrap(),
"/srv/docs/a/b.html"
);
assert_eq!(resolve("/srv/docs/", "/").unwrap(), "/srv/docs");
assert_eq!(resolve("/srv/docs", "/a/./b").unwrap(), "/srv/docs/a/b");
assert_eq!(resolve("/srv/docs", "/a/../b").unwrap(), "/srv/docs/b");
}
#[test]
fn traversal_is_refused_however_it_is_spelled() {
assert!(resolve("/srv/docs", "/../etc/passwd").is_err());
assert!(resolve("/srv/docs", "/a/../../etc/passwd").is_err());
assert!(resolve("/srv/docs", "/%2e%2e/etc/passwd").is_err());
assert!(resolve("/srv/docs", "/%2E%2E%2Fetc/passwd").is_err());
}
#[test]
fn a_nul_byte_is_refused() {
assert!(resolve("/srv/docs", "/a%00b").is_err());
}
}