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::{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/// What a browser says about who started a request.
34///
35/// A forbidden header name: page script can neither set it nor remove it, so what arrives
36/// is the browser's account rather than the caller's.
37pub const FETCH_SITE_HEADER: &str = "sec-fetch-site";
38
39/// Protocol versions this daemon can speak.
40///
41/// Negotiated rather than assumed. The extension ships through a store review and the
42/// daemon ships through cargo, so on any given machine the two will not be the same age
43/// and a new daemon has to keep talking to an old extension.
44pub const PROTOCOL_MIN: u32 = 1;
45pub const PROTOCOL_MAX: u32 = 1;
46
47const TOKEN_BYTES: usize = 32;
48
49/// A bearer token for the control API.
50///
51/// Deliberately neither `Debug` nor `Display`. A token that can be formatted is a token
52/// that ends up in a log line eventually; the only way out is [`Token::as_str`], which
53/// reads as the deliberate act it is.
54pub struct Token(String);
55
56impl Token {
57    pub fn generate() -> Result<Self> {
58        let mut bytes = [0u8; TOKEN_BYTES];
59        // `getrandom::Error` does not implement `std::error::Error`, so it cannot be
60        // attached with `context`.
61        getrandom::fill(&mut bytes)
62            .map_err(|e| anyhow!("reading OS entropy for the control token failed: {e}"))?;
63        Ok(Self(hex(&bytes)))
64    }
65
66    /// Reconstruct a token generated elsewhere, such as one read back from disk.
67    pub fn from_hex(s: &str) -> Self {
68        Self(s.to_string())
69    }
70
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74
75    /// Compare in constant time.
76    ///
77    /// A short-circuiting `==` leaks the token one byte at a time to anything that can
78    /// time the response, and on loopback that is every process on the machine. The
79    /// length is allowed to leak because it is a compile-time constant.
80    pub fn matches(&self, presented: &str) -> bool {
81        let (want, got) = (self.0.as_bytes(), presented.as_bytes());
82        if want.len() != got.len() {
83            return false;
84        }
85        let mut diff = 0u8;
86        for (a, b) in want.iter().zip(got) {
87            diff |= a ^ b;
88        }
89        diff == 0
90    }
91
92    /// Write the token where a local tool can find it, returning where it went.
93    ///
94    /// Best effort. A daemon that cannot write the file still works, because the token
95    /// is printed at startup as well, and refusing to start over this would be worse
96    /// than the inconvenience it avoids.
97    pub fn write_to_disk(&self) -> Option<PathBuf> {
98        let path = token_path()?;
99        std::fs::create_dir_all(path.parent()?).ok()?;
100        write_private(&path, self.0.as_bytes()).ok()?;
101        Some(path)
102    }
103
104    /// Read a token back, if what is on disk is one.
105    ///
106    /// Length and alphabet are both checked. A file holding something else is not a token
107    /// however much one would like it to be, and accepting it would produce a daemon whose
108    /// token nothing can ever match — a locked door with no key, rather than an error.
109    fn from_disk(path: &Path) -> Option<Self> {
110        let text = std::fs::read_to_string(path).ok()?;
111        let trimmed = text.trim();
112        let looks_right = trimmed.len() == TOKEN_BYTES * 2
113            && trimmed
114                .bytes()
115                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase());
116        looks_right.then(|| Self(trimmed.to_string()))
117    }
118
119    /// The token this run will use: last run's, or a new one written down.
120    ///
121    /// Reused by default, because the alternative is what this did before and it made the
122    /// extension unusable. A fresh token every restart means pasting sixty-four characters
123    /// into a popup every time the daemon comes back — and the token was already being
124    /// written to disk, so regenerating took the risk of keeping it there and discarded the
125    /// only thing that risk buys.
126    ///
127    /// `rotate` mints a new one anyway, which is what to reach for if the old one leaked.
128    pub fn load_or_generate(rotate: bool) -> Result<(Self, Source)> {
129        if !rotate
130            && let Some(path) = token_path()
131            && let Some(token) = Self::from_disk(&path)
132        {
133            return Ok((token, Source::Reused(path)));
134        }
135        let token = Self::generate()?;
136        let written = token.write_to_disk();
137        Ok((token, Source::Fresh(written)))
138    }
139}
140
141/// Where the token this run is using came from.
142///
143/// Reported rather than left to be inferred, so the startup banner can say which happened.
144/// Otherwise a reader has to compare a hex string against whatever their browser is holding
145/// in order to find out whether they need to paste it again.
146pub enum Source {
147    /// Read back from a previous run, so a browser that already has it stays connected.
148    Reused(PathBuf),
149    /// Newly minted, and written where the path says — or nowhere, if that failed.
150    Fresh(Option<PathBuf>),
151}
152
153/// Where the token file goes, resolved at runtime rather than compiled in.
154///
155/// The runtime directory is preferred on Unix because it is cleared on logout. That used to
156/// be the whole argument — a token belonging to a running process should not outlive the
157/// session — and it still holds, but it now cuts the other way as well: the token survives a
158/// daemon restart, so a browser stays connected across one, and stops being valid when the
159/// login session that owned it ends. A config directory would keep it indefinitely, which is
160/// longer than anything here needs.
161fn token_path() -> Option<PathBuf> {
162    Some(state_dir()?.join("token"))
163}
164
165/// Where this daemon keeps the small things it remembers between runs.
166///
167/// Shared with anything else that needs one rather than each picking its own: two
168/// directories chosen by two copies of this logic is how a setting gets written to one
169/// place and read from another.
170pub fn state_dir() -> Option<PathBuf> {
171    let base = std::env::var_os("XDG_RUNTIME_DIR")
172        .or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
173        .or_else(|| std::env::var_os("LOCALAPPDATA"))
174        .map(PathBuf::from)
175        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
176    Some(base.join("ssh-browser"))
177}
178
179/// Write a file only this account can read, with the permissions set as it is created.
180///
181/// Created restricted rather than tightened afterwards. Writing the bytes and then calling
182/// `set_permissions` leaves a window in which the file exists and is readable — short, real, and
183/// exactly the kind of detail that stays invisible until it matters. The token has always gone
184/// through here; the authority key in `crate::tls` has to, because a private key another account
185/// on the machine can read is the one thing that makes a constrained CA pointless.
186#[cfg(unix)]
187pub fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
188    use std::io::Write;
189    use std::os::unix::fs::OpenOptionsExt;
190    let mut file = std::fs::OpenOptions::new()
191        .write(true)
192        .create(true)
193        .truncate(true)
194        .mode(0o600)
195        .open(path)?;
196    file.write_all(bytes)
197}
198
199/// On Windows a file created under the user's own `LOCALAPPDATA` inherits an ACL that already
200/// excludes other users, and there is no mode to set.
201#[cfg(not(unix))]
202pub fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
203    std::fs::write(path, bytes)
204}
205
206fn hex(bytes: &[u8]) -> String {
207    let mut out = String::with_capacity(bytes.len() * 2);
208    for b in bytes {
209        out.push(nibble(b >> 4));
210        out.push(nibble(b & 0x0f));
211    }
212    out
213}
214
215fn nibble(n: u8) -> char {
216    match n {
217        0..=9 => (b'0' + n) as char,
218        _ => (b'a' + n - 10) as char,
219    }
220}
221
222#[derive(Serialize)]
223struct Protocol {
224    min: u32,
225    max: u32,
226}
227
228#[derive(Serialize)]
229struct Hello<'a> {
230    daemon: &'a str,
231    protocol: Protocol,
232    aliases: &'a [String],
233    /// The hostname suffix, so the extension can build an alias URL without being told it
234    /// separately.
235    ///
236    /// Reported rather than assumed: the suffix is configurable, and an extension that
237    /// hardcoded it would break the moment somebody changed it. Additive, so a protocol-1
238    /// client that does not read this field is unaffected and the range stays 1..=1.
239    suffix: &'a str,
240    /// What TLS handshakes have done, under https.
241    ///
242    /// `None` under http, where there is nothing to hand shake about. Under https this is the
243    /// only honest answer to "is the authority trusted": nothing can ask a trust store portably,
244    /// but a handshake that completed proves a browser accepted the certificate and one that
245    /// failed almost always means it did not.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    tls: Option<Handshakes>,
248    /// `http` or `https`, so the extension builds a URL in the scheme being served.
249    ///
250    /// Reported for the same reason the suffix is: it is configurable, and an extension that
251    /// assumed would hand somebody a link to a *different origin* than the one being served —
252    /// which under https is not a cosmetic difference.
253    scheme: &'a str,
254    /// Remote round trips every open session has cost, added up.
255    ///
256    /// Here as well as in `hosts` because this is the cheap route. `hosts` runs `ssh -G` once
257    /// per configured host, which is the right cost for a list somebody is about to read and
258    /// the wrong cost for a number sampled twice around a page load: the measurement takes
259    /// long enough to expire the listings it is measuring. That mistake has been made twice
260    /// here already. A counter nobody can read without disturbing is not a counter.
261    trips: u64,
262}
263
264/// Check the two things that must hold before any control route runs, returning the
265/// refusal if there is one.
266///
267/// Separated from routing so that a caller cannot reach a route without going through it:
268/// there is no path to a control route that does not pass this function first.
269/// Whether a request could have come from a page.
270///
271/// Measured rather than assumed. In Chromium an extension's `fetch` arrives with
272/// `Sec-Fetch-Site: none` and no `Origin` at all, while a page the daemon itself serves in
273/// the no-proxy fallback mode -- which is *same-origin* with the control API, and so the
274/// hardest case -- arrives with `same-origin`. Anything from another site is `cross-site`.
275///
276/// Absent means no browser sent it. That is a local process, which could read the token
277/// file directly, so refusing it here would protect nothing.
278pub fn from_a_page(site: Option<&str>) -> bool {
279    match site {
280        None => false,
281        Some("none") => false,
282        Some(_) => true,
283    }
284}
285
286pub fn gate(
287    method: &Method,
288    fetch_site: Option<&str>,
289    presented: Option<&str>,
290    token: &Token,
291) -> Option<Response<Full<Bytes>>> {
292    // Refusing the preflight is what keeps an alias page from ever reaching a route.
293    // Answering it, even with a restrictive allow-list, would move the decision into the
294    // browser's hands rather than ours.
295    if method == Method::OPTIONS {
296        return Some(text(
297            StatusCode::METHOD_NOT_ALLOWED,
298            "the control API does not participate in CORS",
299        ));
300    }
301
302    // Before the token, because it is a stronger statement: no page reaches this API at
303    // all, whatever it has got hold of. The token answers "is this caller authorised";
304    // this answers "is this caller a page", and a page holding a leaked token was the one
305    // case the token alone could not refuse. It matters most in the no-proxy fallback
306    // mode, where a page the daemon serves shares an origin with the control API.
307    if from_a_page(fetch_site) {
308        return Some(text(
309            StatusCode::FORBIDDEN,
310            "the control API is not reachable from a page",
311        ));
312    }
313
314    match presented {
315        Some(p) if token.matches(p) => None,
316        // The same answer either way: distinguishing "no token" from "wrong token" would
317        // tell a caller which half it got right.
318        _ => Some(text(StatusCode::UNAUTHORIZED, "control token required")),
319    }
320}
321
322/// The route name within the control namespace, e.g. `hello`.
323pub fn route_of(path: &str) -> &str {
324    path.strip_prefix(PATH_PREFIX).unwrap_or("")
325}
326
327/// Completed and failed TLS handshakes, as the extension sees them.
328#[derive(Serialize, Clone, Copy)]
329pub struct Handshakes {
330    pub completed: u64,
331    pub failed: u64,
332}
333
334pub fn hello(
335    aliases: &[String],
336    suffix: &str,
337    scheme: &str,
338    trips: u64,
339    tls: Option<Handshakes>,
340) -> Response<Full<Bytes>> {
341    json(&Hello {
342        daemon: env!("CARGO_PKG_VERSION"),
343        protocol: Protocol {
344            min: PROTOCOL_MIN,
345            max: PROTOCOL_MAX,
346        },
347        aliases,
348        suffix,
349        scheme,
350        trips,
351        tls,
352    })
353}
354
355pub fn json<T: Serialize>(value: &T) -> Response<Full<Bytes>> {
356    match serde_json::to_vec(value) {
357        Ok(body) => Response::builder()
358            .status(StatusCode::OK)
359            .header(CONTENT_TYPE, "application/json")
360            .body(Full::new(Bytes::from(body)))
361            .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "malformed response")),
362        Err(e) => text(
363            StatusCode::INTERNAL_SERVER_ERROR,
364            format!("serialising the response failed: {e}"),
365        ),
366    }
367}
368
369pub fn text(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
370    Response::builder()
371        .status(status)
372        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
373        .body(Full::new(Bytes::from(detail.into())))
374        .expect("a plain-text body with static headers always builds")
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn token() -> Token {
382        Token::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
383    }
384
385    #[test]
386    fn a_generated_token_is_long_and_random() {
387        let a = Token::generate().expect("OS entropy");
388        let b = Token::generate().expect("OS entropy");
389        assert_eq!(a.as_str().len(), TOKEN_BYTES * 2);
390        assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
391        assert_ne!(
392            a.as_str(),
393            b.as_str(),
394            "two tokens from the same process must differ"
395        );
396    }
397
398    #[test]
399    fn the_right_token_passes_the_gate() {
400        assert!(gate(&Method::GET, None, Some(token().as_str()), &token()).is_none());
401    }
402
403    #[test]
404    fn a_missing_or_wrong_token_is_refused_identically() {
405        for presented in [None, Some(""), Some("wrong"), Some(&token().as_str()[..10])] {
406            let refusal = gate(&Method::GET, None, presented, &token()).expect("refused");
407            assert_eq!(refusal.status(), StatusCode::UNAUTHORIZED);
408        }
409    }
410
411    /// The boundary. If a preflight ever passes, an untrusted page can start negotiating
412    /// with the control API instead of being stopped before the request is even made.
413    #[test]
414    fn a_preflight_is_refused_even_with_a_valid_token() {
415        let refusal =
416            gate(&Method::OPTIONS, None, Some(token().as_str()), &token()).expect("refused");
417        assert_eq!(refusal.status(), StatusCode::METHOD_NOT_ALLOWED);
418    }
419
420    /// Nothing here may emit CORS headers: that is what stops a page reading a response
421    /// even if it somehow manages to send the request.
422    #[test]
423    fn no_response_carries_cors_headers() {
424        let mut responses = vec![hello(&["docs".to_string()], "ssh-browser", "http", 0, None)];
425        responses.extend(gate(&Method::OPTIONS, None, None, &token()));
426        responses.extend(gate(&Method::GET, None, None, &token()));
427        responses.push(text(StatusCode::NOT_FOUND, "nope"));
428
429        for res in responses {
430            for name in res.headers().keys() {
431                let lowered = name.as_str().to_ascii_lowercase();
432                assert!(
433                    !lowered.starts_with("access-control-"),
434                    "a control response carries {lowered}"
435                );
436            }
437        }
438    }
439
440    #[test]
441    fn hello_reports_a_protocol_range_and_the_aliases() {
442        let body = serde_json::to_string(&Hello {
443            daemon: env!("CARGO_PKG_VERSION"),
444            protocol: Protocol {
445                min: PROTOCOL_MIN,
446                max: PROTOCOL_MAX,
447            },
448            aliases: &["docs".to_string()],
449            suffix: "ssh-browser",
450            scheme: "https",
451            trips: 7,
452            tls: Some(Handshakes {
453                completed: 0,
454                failed: 3,
455            }),
456        })
457        .expect("serialises");
458        assert!(body.contains("\"min\":1"));
459        assert!(body.contains("\"max\":1"));
460        assert!(body.contains("\"aliases\":[\"docs\"]"));
461        assert!(body.contains("\"daemon\":\""));
462        // The cheap route carries it too, so a measurement does not have to pay for `hosts`.
463        assert!(body.contains("\"trips\":7"), "{body}");
464        // The scheme, because the extension builds URLs from it and a wrong one is a link into
465        // a different origin than the one being served.
466        assert!(body.contains("\"scheme\":\"https\""), "{body}");
467        // What handshakes have done, which is the only portable answer to "is the authority
468        // trusted". Three failures and no successes is a reader who has not run
469        // `ssh-browser trust` yet, and the dashboard has to be able to say so.
470        assert!(body.contains("\"failed\":3"), "{body}");
471    }
472
473    /// A temporary file, named after the test so parallel runs cannot collide.
474    fn scratch(name: &str, contents: &str) -> std::path::PathBuf {
475        let path = std::env::temp_dir().join(format!("ssh-browser-token-{name}"));
476        std::fs::write(&path, contents).expect("a temp file");
477        path
478    }
479
480    /// The whole point of keeping it: a browser that has the token stays connected across a
481    /// restart, so nobody retypes sixty-four characters to get back to where they were.
482    #[test]
483    fn a_token_survives_the_round_trip_to_disk() {
484        let path = scratch("roundtrip", token().as_str());
485        let back = Token::from_disk(&path).expect("read back");
486        assert!(back.matches(token().as_str()));
487        let _ = std::fs::remove_file(&path);
488    }
489
490    /// Anything that is not a token is not accepted as one. Taking it would produce a daemon
491    /// whose token nothing can ever match — a locked door with no key rather than an error,
492    /// and one that only shows up as a 401 on every request.
493    #[test]
494    fn a_file_that_is_not_a_token_is_refused() {
495        let cases = [
496            ("empty", ""),
497            ("short", "0123456789abcdef"),
498            ("long", &"a".repeat(65) as &str),
499            ("not-hex", &"z".repeat(64)),
500            // Uppercase would compare unequal to everything this ever generates, so it is
501            // refused rather than quietly accepted and never matched.
502            ("uppercase", &"A".repeat(64)),
503            ("a sentence", "this file used to hold a token"),
504        ];
505        for (name, contents) in cases {
506            let path = scratch(name, contents);
507            assert!(
508                Token::from_disk(&path).is_none(),
509                "{name:?} should not have read as a token"
510            );
511            let _ = std::fs::remove_file(&path);
512        }
513    }
514
515    /// Written with a trailing newline by an editor, or by anyone who opened it to look.
516    #[test]
517    fn surrounding_whitespace_does_not_spoil_it() {
518        let path = scratch("whitespace", &format!("\n  {}\t\n", token().as_str()));
519        assert!(
520            Token::from_disk(&path)
521                .expect("read back")
522                .matches(token().as_str())
523        );
524        let _ = std::fs::remove_file(&path);
525    }
526
527    #[test]
528    fn routes_are_named_after_the_prefix() {
529        assert_eq!(route_of("/_control/hello"), "hello");
530        assert_eq!(route_of("/_control/open"), "open");
531        assert_eq!(route_of("/not-control"), "");
532    }
533
534    /// The measured cases. An extension's fetch arrives as `none` with no `Origin`; a page
535    /// the daemon serves in the no-proxy fallback mode arrives as `same-origin`, which is
536    /// the hardest one because it shares an origin with the control API; anything from
537    /// elsewhere is `cross-site`. Absent is not a browser at all.
538    #[test]
539    fn a_page_is_told_apart_from_an_extension() {
540        assert!(!from_a_page(None));
541        assert!(!from_a_page(Some("none")));
542        for page in ["same-origin", "same-site", "cross-site"] {
543            assert!(from_a_page(Some(page)), "{page} is a page");
544        }
545    }
546
547    /// Refused before the token is even looked at, because it is the stronger statement:
548    /// a page holding a leaked token is the one case the token alone could not refuse.
549    #[test]
550    fn a_page_cannot_reach_the_control_api_even_with_the_right_token() {
551        let refusal = gate(
552            &Method::GET,
553            Some("same-origin"),
554            Some(token().as_str()),
555            &token(),
556        )
557        .expect("refused");
558        assert_eq!(refusal.status(), StatusCode::FORBIDDEN);
559    }
560}