Skip to main content

ssh_browser/origin/
guard.rs

1//! Request guards: two separate checks for two separate attacks.
2//!
3//! The Host check stops a DNS-rebinding site from reading the remote through the
4//! loopback listener. Binding to 127.0.0.1 does nothing about that on its own,
5//! and leaving it out is the hole rclone's `serve http` has.
6//!
7//! The path check keeps a request inside its alias base.
8
9use anyhow::{Context, Result, bail, ensure};
10
11/// Which shape of request arrived.
12#[derive(Debug, PartialEq, Eq)]
13pub enum Target<'a> {
14    /// Proxied: the browser asked for `http://<alias>.<suffix>/<path>`.
15    Alias { alias: &'a str, path: &'a str },
16    /// Direct: something reached the loopback listener by address.
17    Direct { path: &'a str },
18}
19
20/// Decide what a request is, or refuse it.
21pub 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        // A proxied request carries the site's own port, normally none or 80.
33        // Anything else is not something we handed out.
34        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        // The port must be ours. A rebinding site resolved to loopback would
43        // still arrive carrying its own Host, which the check above already
44        // rejected, but pinning the port keeps the direct path honest too.
45        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    // Bracketed IPv6 literal: the colons inside the brackets are not a port.
57    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
69/// The shape a hostname label has to have, and the rule every request is held to.
70///
71/// Crate-visible so that `Alias::new` checks exactly this rather than a second copy of
72/// it. The copies had already drifted: `-docs` passed the constructor and was then
73/// refused by `classify` on every request, so the daemon paid for the ssh connection,
74/// printed the route and listed it as a link that could not work.
75pub(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
83/// Resolve a request path against an alias base, refusing anything that escapes.
84///
85/// Deliberately string-only. Asking the remote to REALPATH every request would
86/// add a round trip per request and break invariant 1. The cost is real and worth
87/// stating plainly: a symlink inside the base that points outside it is not
88/// caught here. That check needs the listing cache and arrives with it.
89pub 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    /// The whole point of the Host check: binding to loopback does not stop a
164    /// rebinding site, only refusing its Host does.
165    #[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        // Percent-encoded dot-dot must be decoded before normalising, or it walks
195        // straight through.
196        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}