Skip to main content

ssh_browser/control/
mod.rs

1//! The control API: the only path that will ever be allowed to write.
2//!
3//! No CORS headers are emitted anywhere in this module, and `OPTIONS` is refused. That
4//! combination is the security boundary, so it is worth spelling out.
5//!
6//! A page served under an alias origin is untrusted code. If it tries to reach the
7//! control API it has to send the token header; a custom header is not CORS-safelisted,
8//! so sending it forces a preflight; and a refused preflight means the request is never
9//! made. Without the header the request is a 401 instead. An extension is outside CORS
10//! by virtue of its host permissions, so none of this impedes it.
11//!
12//! The listener also only routes here for requests whose Host is the loopback address,
13//! which `guard::classify` already separates from alias requests. A proxied request
14//! cannot arrive here at all.
15
16use std::path::PathBuf;
17
18use anyhow::{Result, anyhow};
19use bytes::Bytes;
20use http_body_util::Full;
21use hyper::header::CONTENT_TYPE;
22use hyper::{Method, Response, StatusCode};
23use serde::Serialize;
24
25/// The header the token must arrive in.
26///
27/// Custom rather than `Authorization` for one reason that matters: a custom header is
28/// not CORS-safelisted, so a page attempting to send it triggers a preflight we refuse.
29pub const TOKEN_HEADER: &str = "x-ssh-browser-token";
30
31pub const PATH_PREFIX: &str = "/_control/";
32
33/// Protocol versions this daemon can speak.
34///
35/// Negotiated rather than assumed. The extension ships through a store review and the
36/// daemon ships through cargo, so on any given machine the two will not be the same age
37/// and a new daemon has to keep talking to an old extension.
38pub const PROTOCOL_MIN: u32 = 1;
39pub const PROTOCOL_MAX: u32 = 1;
40
41const TOKEN_BYTES: usize = 32;
42
43/// A bearer token for the control API.
44///
45/// Deliberately neither `Debug` nor `Display`. A token that can be formatted is a token
46/// that ends up in a log line eventually; the only way out is [`Token::as_str`], which
47/// reads as the deliberate act it is.
48pub struct Token(String);
49
50impl Token {
51    pub fn generate() -> Result<Self> {
52        let mut bytes = [0u8; TOKEN_BYTES];
53        // `getrandom::Error` does not implement `std::error::Error`, so it cannot be
54        // attached with `context`.
55        getrandom::fill(&mut bytes)
56            .map_err(|e| anyhow!("reading OS entropy for the control token failed: {e}"))?;
57        Ok(Self(hex(&bytes)))
58    }
59
60    /// Reconstruct a token generated elsewhere, such as one read back from disk.
61    pub fn from_hex(s: &str) -> Self {
62        Self(s.to_string())
63    }
64
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68
69    /// Compare in constant time.
70    ///
71    /// A short-circuiting `==` leaks the token one byte at a time to anything that can
72    /// time the response, and on loopback that is every process on the machine. The
73    /// length is allowed to leak because it is a compile-time constant.
74    pub fn matches(&self, presented: &str) -> bool {
75        let (want, got) = (self.0.as_bytes(), presented.as_bytes());
76        if want.len() != got.len() {
77            return false;
78        }
79        let mut diff = 0u8;
80        for (a, b) in want.iter().zip(got) {
81            diff |= a ^ b;
82        }
83        diff == 0
84    }
85
86    /// Write the token where a local tool can find it, returning where it went.
87    ///
88    /// Best effort. A daemon that cannot write the file still works, because the token
89    /// is printed at startup as well, and refusing to start over this would be worse
90    /// than the inconvenience it avoids.
91    pub fn write_to_disk(&self) -> Option<PathBuf> {
92        let path = token_path()?;
93        std::fs::create_dir_all(path.parent()?).ok()?;
94        std::fs::write(&path, &self.0).ok()?;
95        restrict(&path);
96        Some(path)
97    }
98}
99
100/// Where the token file goes, resolved at runtime rather than compiled in.
101///
102/// The runtime directory is preferred on Unix because it is cleared on logout, which is
103/// the right lifetime for a token belonging to a running process. A config directory
104/// would keep a dead token around indefinitely.
105fn token_path() -> Option<PathBuf> {
106    let base = std::env::var_os("XDG_RUNTIME_DIR")
107        .or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
108        .or_else(|| std::env::var_os("LOCALAPPDATA"))
109        .map(PathBuf::from)
110        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
111    Some(base.join("ssh-browser").join("token"))
112}
113
114#[cfg(unix)]
115fn restrict(path: &std::path::Path) {
116    use std::os::unix::fs::PermissionsExt;
117    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
118}
119
120#[cfg(not(unix))]
121fn restrict(_path: &std::path::Path) {
122    // On Windows a file created under the user's own LOCALAPPDATA inherits an ACL that
123    // already excludes other users, and there is no mode to set.
124}
125
126fn hex(bytes: &[u8]) -> String {
127    let mut out = String::with_capacity(bytes.len() * 2);
128    for b in bytes {
129        out.push(nibble(b >> 4));
130        out.push(nibble(b & 0x0f));
131    }
132    out
133}
134
135fn nibble(n: u8) -> char {
136    match n {
137        0..=9 => (b'0' + n) as char,
138        _ => (b'a' + n - 10) as char,
139    }
140}
141
142#[derive(Serialize)]
143struct Protocol {
144    min: u32,
145    max: u32,
146}
147
148#[derive(Serialize)]
149struct Hello<'a> {
150    daemon: &'a str,
151    protocol: Protocol,
152    aliases: &'a [String],
153    /// The hostname suffix, so the extension can build an alias URL without being told it
154    /// separately.
155    ///
156    /// Reported rather than assumed: the suffix is configurable, and an extension that
157    /// hardcoded it would break the moment somebody changed it. Additive, so a protocol-1
158    /// client that does not read this field is unaffected and the range stays 1..=1.
159    suffix: &'a str,
160}
161
162/// Check the two things that must hold before any control route runs, returning the
163/// refusal if there is one.
164///
165/// Separated from routing so that a caller cannot reach a route without going through it:
166/// there is no path to the annotation handlers that does not pass this function first.
167pub fn gate(
168    method: &Method,
169    presented: Option<&str>,
170    token: &Token,
171) -> Option<Response<Full<Bytes>>> {
172    // Refusing the preflight is what keeps an alias page from ever reaching a route.
173    // Answering it, even with a restrictive allow-list, would move the decision into the
174    // browser's hands rather than ours.
175    if method == Method::OPTIONS {
176        return Some(text(
177            StatusCode::METHOD_NOT_ALLOWED,
178            "the control API does not participate in CORS",
179        ));
180    }
181
182    match presented {
183        Some(p) if token.matches(p) => None,
184        // The same answer either way: distinguishing "no token" from "wrong token" would
185        // tell a caller which half it got right.
186        _ => Some(text(StatusCode::UNAUTHORIZED, "control token required")),
187    }
188}
189
190/// The route name within the control namespace, e.g. `hello`.
191pub fn route_of(path: &str) -> &str {
192    path.strip_prefix(PATH_PREFIX).unwrap_or("")
193}
194
195pub fn hello(aliases: &[String], suffix: &str) -> Response<Full<Bytes>> {
196    json(&Hello {
197        daemon: env!("CARGO_PKG_VERSION"),
198        protocol: Protocol {
199            min: PROTOCOL_MIN,
200            max: PROTOCOL_MAX,
201        },
202        aliases,
203        suffix,
204    })
205}
206
207pub fn json<T: Serialize>(value: &T) -> Response<Full<Bytes>> {
208    match serde_json::to_vec(value) {
209        Ok(body) => Response::builder()
210            .status(StatusCode::OK)
211            .header(CONTENT_TYPE, "application/json")
212            .body(Full::new(Bytes::from(body)))
213            .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "malformed response")),
214        Err(e) => text(
215            StatusCode::INTERNAL_SERVER_ERROR,
216            format!("serialising the response failed: {e}"),
217        ),
218    }
219}
220
221pub fn text(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
222    Response::builder()
223        .status(status)
224        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
225        .body(Full::new(Bytes::from(detail.into())))
226        .expect("a plain-text body with static headers always builds")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn token() -> Token {
234        Token::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
235    }
236
237    #[test]
238    fn a_generated_token_is_long_and_random() {
239        let a = Token::generate().expect("OS entropy");
240        let b = Token::generate().expect("OS entropy");
241        assert_eq!(a.as_str().len(), TOKEN_BYTES * 2);
242        assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
243        assert_ne!(
244            a.as_str(),
245            b.as_str(),
246            "two tokens from the same process must differ"
247        );
248    }
249
250    #[test]
251    fn the_right_token_passes_the_gate() {
252        assert!(gate(&Method::GET, Some(token().as_str()), &token()).is_none());
253    }
254
255    #[test]
256    fn a_missing_or_wrong_token_is_refused_identically() {
257        for presented in [None, Some(""), Some("wrong"), Some(&token().as_str()[..10])] {
258            let refusal = gate(&Method::GET, presented, &token()).expect("refused");
259            assert_eq!(refusal.status(), StatusCode::UNAUTHORIZED);
260        }
261    }
262
263    /// The boundary. If a preflight ever passes, an untrusted page can start negotiating
264    /// with the control API instead of being stopped before the request is even made.
265    #[test]
266    fn a_preflight_is_refused_even_with_a_valid_token() {
267        let refusal = gate(&Method::OPTIONS, Some(token().as_str()), &token()).expect("refused");
268        assert_eq!(refusal.status(), StatusCode::METHOD_NOT_ALLOWED);
269    }
270
271    /// Nothing here may emit CORS headers: that is what stops a page reading a response
272    /// even if it somehow manages to send the request.
273    #[test]
274    fn no_response_carries_cors_headers() {
275        let mut responses = vec![hello(&["docs".to_string()], "ssh-browser")];
276        responses.extend(gate(&Method::OPTIONS, None, &token()));
277        responses.extend(gate(&Method::GET, None, &token()));
278        responses.push(text(StatusCode::NOT_FOUND, "nope"));
279
280        for res in responses {
281            for name in res.headers().keys() {
282                let lowered = name.as_str().to_ascii_lowercase();
283                assert!(
284                    !lowered.starts_with("access-control-"),
285                    "a control response carries {lowered}"
286                );
287            }
288        }
289    }
290
291    #[test]
292    fn hello_reports_a_protocol_range_and_the_aliases() {
293        let body = serde_json::to_string(&Hello {
294            daemon: env!("CARGO_PKG_VERSION"),
295            protocol: Protocol {
296                min: PROTOCOL_MIN,
297                max: PROTOCOL_MAX,
298            },
299            aliases: &["docs".to_string()],
300            suffix: "ssh-browser",
301        })
302        .expect("serialises");
303        assert!(body.contains("\"min\":1"));
304        assert!(body.contains("\"max\":1"));
305        assert!(body.contains("\"aliases\":[\"docs\"]"));
306        assert!(body.contains("\"daemon\":\""));
307    }
308
309    #[test]
310    fn routes_are_named_after_the_prefix() {
311        assert_eq!(route_of("/_control/hello"), "hello");
312        assert_eq!(route_of("/_control/annotations"), "annotations");
313        assert_eq!(route_of("/not-control"), "");
314    }
315}