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    /// Proxied, at the suffix itself: `http://<suffix>/`, with no alias label.
17    ///
18    /// The front door. The PAC has always routed this — `host === "<suffix>"` is in the script —
19    /// and the https certificate has always covered it, but nothing answered it, so the browser
20    /// was sent here and refused. Routing somewhere that refuses is worse than not routing.
21    Index { path: &'a str },
22    /// Direct: something reached the loopback listener by address.
23    Direct { path: &'a str },
24}
25
26/// Decide what a request is, or refuse it.
27pub 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        // A proxied request carries the site's own port, normally none or 80.
39        // Anything else is not something we handed out.
40        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    // The suffix with no label in front of it. Held to the same port rule as an alias, because
48    // it arrives the same way and for the same reason.
49    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        // The port must be ours. A rebinding site resolved to loopback would
59        // still arrive carrying its own Host, which the check above already
60        // rejected, but pinning the port keeps the direct path honest too.
61        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    // Bracketed IPv6 literal: the colons inside the brackets are not a port.
73    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
85/// The shape a hostname label has to have, and the rule every request is held to.
86///
87/// Crate-visible so that `Alias::new` checks exactly this rather than a second copy of
88/// it. The copies had already drifted: `-docs` passed the constructor and was then
89/// refused by `classify` on every request, so the daemon paid for the ssh connection,
90/// printed the route and listed it as a link that could not work.
91pub(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
99/// Resolve a request path against an alias base, refusing anything that escapes.
100///
101/// Deliberately string-only. Asking the remote to REALPATH every request would
102/// add a round trip per request and break invariant 1. The cost is real and worth
103/// stating plainly: a symlink inside the base that points outside it is not
104/// caught here. That check needs the listing cache and arrives with it.
105pub 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    /// The suffix on its own is the index, not a refusal.
161    ///
162    /// The PAC has always routed it — `host === "<suffix>"` is in the script it serves — and the
163    /// https certificate has always carried it as a name. Only the daemon disagreed, so a
164    /// browser following the PAC arrived here and was told `403`. Routing somewhere that refuses
165    /// is worse than not routing.
166    #[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        // With the ports a browser actually sends for http and https.
173        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        // And not on a port nobody handed out, which is the same rule an alias is held to.
182        assert!(classify("ssh-browser:9999", "/", "ssh-browser", 7391).is_err());
183    }
184
185    /// A host that merely ends with the suffix is still somebody else's.
186    ///
187    /// The index arm compares the whole name, so this cannot become a way in. It is the same
188    /// case the PAC is careful about, checked on the other side of the routing decision.
189    #[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    /// The whole point of the Host check: binding to loopback does not stop a
216    /// rebinding site, only refusing its Host does.
217    #[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        // Percent-encoded dot-dot must be decoded before normalising, or it walks
247        // straight through.
248        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}