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 std::fs::write(&path, &self.0).ok()?;
101 restrict(&path);
102 Some(path)
103 }
104
105 /// Read a token back, if what is on disk is one.
106 ///
107 /// Length and alphabet are both checked. A file holding something else is not a token
108 /// however much one would like it to be, and accepting it would produce a daemon whose
109 /// token nothing can ever match — a locked door with no key, rather than an error.
110 fn from_disk(path: &Path) -> Option<Self> {
111 let text = std::fs::read_to_string(path).ok()?;
112 let trimmed = text.trim();
113 let looks_right = trimmed.len() == TOKEN_BYTES * 2
114 && trimmed
115 .bytes()
116 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase());
117 looks_right.then(|| Self(trimmed.to_string()))
118 }
119
120 /// The token this run will use: last run's, or a new one written down.
121 ///
122 /// Reused by default, because the alternative is what this did before and it made the
123 /// extension unusable. A fresh token every restart means pasting sixty-four characters
124 /// into a popup every time the daemon comes back — and the token was already being
125 /// written to disk, so regenerating took the risk of keeping it there and discarded the
126 /// only thing that risk buys.
127 ///
128 /// `rotate` mints a new one anyway, which is what to reach for if the old one leaked.
129 pub fn load_or_generate(rotate: bool) -> Result<(Self, Source)> {
130 if !rotate {
131 if let Some(path) = token_path() {
132 if let Some(token) = Self::from_disk(&path) {
133 return Ok((token, Source::Reused(path)));
134 }
135 }
136 }
137 let token = Self::generate()?;
138 let written = token.write_to_disk();
139 Ok((token, Source::Fresh(written)))
140 }
141}
142
143/// Where the token this run is using came from.
144///
145/// Reported rather than left to be inferred, so the startup banner can say which happened.
146/// Otherwise a reader has to compare a hex string against whatever their browser is holding
147/// in order to find out whether they need to paste it again.
148pub enum Source {
149 /// Read back from a previous run, so a browser that already has it stays connected.
150 Reused(PathBuf),
151 /// Newly minted, and written where the path says — or nowhere, if that failed.
152 Fresh(Option<PathBuf>),
153}
154
155/// Where the token file goes, resolved at runtime rather than compiled in.
156///
157/// The runtime directory is preferred on Unix because it is cleared on logout. That used to
158/// be the whole argument — a token belonging to a running process should not outlive the
159/// session — and it still holds, but it now cuts the other way as well: the token survives a
160/// daemon restart, so a browser stays connected across one, and stops being valid when the
161/// login session that owned it ends. A config directory would keep it indefinitely, which is
162/// longer than anything here needs.
163fn token_path() -> Option<PathBuf> {
164 Some(state_dir()?.join("token"))
165}
166
167/// Where this daemon keeps the small things it remembers between runs.
168///
169/// Shared with anything else that needs one rather than each picking its own: two
170/// directories chosen by two copies of this logic is how a setting gets written to one
171/// place and read from another.
172pub fn state_dir() -> Option<PathBuf> {
173 let base = std::env::var_os("XDG_RUNTIME_DIR")
174 .or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
175 .or_else(|| std::env::var_os("LOCALAPPDATA"))
176 .map(PathBuf::from)
177 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
178 Some(base.join("ssh-browser"))
179}
180
181#[cfg(unix)]
182fn restrict(path: &std::path::Path) {
183 use std::os::unix::fs::PermissionsExt;
184 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
185}
186
187#[cfg(not(unix))]
188fn restrict(_path: &std::path::Path) {
189 // On Windows a file created under the user's own LOCALAPPDATA inherits an ACL that
190 // already excludes other users, and there is no mode to set.
191}
192
193fn hex(bytes: &[u8]) -> String {
194 let mut out = String::with_capacity(bytes.len() * 2);
195 for b in bytes {
196 out.push(nibble(b >> 4));
197 out.push(nibble(b & 0x0f));
198 }
199 out
200}
201
202fn nibble(n: u8) -> char {
203 match n {
204 0..=9 => (b'0' + n) as char,
205 _ => (b'a' + n - 10) as char,
206 }
207}
208
209#[derive(Serialize)]
210struct Protocol {
211 min: u32,
212 max: u32,
213}
214
215#[derive(Serialize)]
216struct Hello<'a> {
217 daemon: &'a str,
218 protocol: Protocol,
219 aliases: &'a [String],
220 /// The hostname suffix, so the extension can build an alias URL without being told it
221 /// separately.
222 ///
223 /// Reported rather than assumed: the suffix is configurable, and an extension that
224 /// hardcoded it would break the moment somebody changed it. Additive, so a protocol-1
225 /// client that does not read this field is unaffected and the range stays 1..=1.
226 suffix: &'a str,
227 /// Remote round trips every open session has cost, added up.
228 ///
229 /// Here as well as in `hosts` because this is the cheap route. `hosts` runs `ssh -G` once
230 /// per configured host, which is the right cost for a list somebody is about to read and
231 /// the wrong cost for a number sampled twice around a page load: the measurement takes
232 /// long enough to expire the listings it is measuring. That mistake has been made twice
233 /// here already. A counter nobody can read without disturbing is not a counter.
234 trips: u64,
235}
236
237/// Check the two things that must hold before any control route runs, returning the
238/// refusal if there is one.
239///
240/// Separated from routing so that a caller cannot reach a route without going through it:
241/// there is no path to a control route that does not pass this function first.
242/// Whether a request could have come from a page.
243///
244/// Measured rather than assumed. In Chromium an extension's `fetch` arrives with
245/// `Sec-Fetch-Site: none` and no `Origin` at all, while a page the daemon itself serves in
246/// the no-proxy fallback mode -- which is *same-origin* with the control API, and so the
247/// hardest case -- arrives with `same-origin`. Anything from another site is `cross-site`.
248///
249/// Absent means no browser sent it. That is a local process, which could read the token
250/// file directly, so refusing it here would protect nothing.
251pub fn from_a_page(site: Option<&str>) -> bool {
252 match site {
253 None => false,
254 Some("none") => false,
255 Some(_) => true,
256 }
257}
258
259pub fn gate(
260 method: &Method,
261 fetch_site: Option<&str>,
262 presented: Option<&str>,
263 token: &Token,
264) -> Option<Response<Full<Bytes>>> {
265 // Refusing the preflight is what keeps an alias page from ever reaching a route.
266 // Answering it, even with a restrictive allow-list, would move the decision into the
267 // browser's hands rather than ours.
268 if method == Method::OPTIONS {
269 return Some(text(
270 StatusCode::METHOD_NOT_ALLOWED,
271 "the control API does not participate in CORS",
272 ));
273 }
274
275 // Before the token, because it is a stronger statement: no page reaches this API at
276 // all, whatever it has got hold of. The token answers "is this caller authorised";
277 // this answers "is this caller a page", and a page holding a leaked token was the one
278 // case the token alone could not refuse. It matters most in the no-proxy fallback
279 // mode, where a page the daemon serves shares an origin with the control API.
280 if from_a_page(fetch_site) {
281 return Some(text(
282 StatusCode::FORBIDDEN,
283 "the control API is not reachable from a page",
284 ));
285 }
286
287 match presented {
288 Some(p) if token.matches(p) => None,
289 // The same answer either way: distinguishing "no token" from "wrong token" would
290 // tell a caller which half it got right.
291 _ => Some(text(StatusCode::UNAUTHORIZED, "control token required")),
292 }
293}
294
295/// The route name within the control namespace, e.g. `hello`.
296pub fn route_of(path: &str) -> &str {
297 path.strip_prefix(PATH_PREFIX).unwrap_or("")
298}
299
300pub fn hello(aliases: &[String], suffix: &str, trips: u64) -> Response<Full<Bytes>> {
301 json(&Hello {
302 daemon: env!("CARGO_PKG_VERSION"),
303 protocol: Protocol {
304 min: PROTOCOL_MIN,
305 max: PROTOCOL_MAX,
306 },
307 aliases,
308 suffix,
309 trips,
310 })
311}
312
313pub fn json<T: Serialize>(value: &T) -> Response<Full<Bytes>> {
314 match serde_json::to_vec(value) {
315 Ok(body) => Response::builder()
316 .status(StatusCode::OK)
317 .header(CONTENT_TYPE, "application/json")
318 .body(Full::new(Bytes::from(body)))
319 .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "malformed response")),
320 Err(e) => text(
321 StatusCode::INTERNAL_SERVER_ERROR,
322 format!("serialising the response failed: {e}"),
323 ),
324 }
325}
326
327pub fn text(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
328 Response::builder()
329 .status(status)
330 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
331 .body(Full::new(Bytes::from(detail.into())))
332 .expect("a plain-text body with static headers always builds")
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn token() -> Token {
340 Token::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
341 }
342
343 #[test]
344 fn a_generated_token_is_long_and_random() {
345 let a = Token::generate().expect("OS entropy");
346 let b = Token::generate().expect("OS entropy");
347 assert_eq!(a.as_str().len(), TOKEN_BYTES * 2);
348 assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
349 assert_ne!(
350 a.as_str(),
351 b.as_str(),
352 "two tokens from the same process must differ"
353 );
354 }
355
356 #[test]
357 fn the_right_token_passes_the_gate() {
358 assert!(gate(&Method::GET, None, Some(token().as_str()), &token()).is_none());
359 }
360
361 #[test]
362 fn a_missing_or_wrong_token_is_refused_identically() {
363 for presented in [None, Some(""), Some("wrong"), Some(&token().as_str()[..10])] {
364 let refusal = gate(&Method::GET, None, presented, &token()).expect("refused");
365 assert_eq!(refusal.status(), StatusCode::UNAUTHORIZED);
366 }
367 }
368
369 /// The boundary. If a preflight ever passes, an untrusted page can start negotiating
370 /// with the control API instead of being stopped before the request is even made.
371 #[test]
372 fn a_preflight_is_refused_even_with_a_valid_token() {
373 let refusal =
374 gate(&Method::OPTIONS, None, Some(token().as_str()), &token()).expect("refused");
375 assert_eq!(refusal.status(), StatusCode::METHOD_NOT_ALLOWED);
376 }
377
378 /// Nothing here may emit CORS headers: that is what stops a page reading a response
379 /// even if it somehow manages to send the request.
380 #[test]
381 fn no_response_carries_cors_headers() {
382 let mut responses = vec![hello(&["docs".to_string()], "ssh-browser", 0)];
383 responses.extend(gate(&Method::OPTIONS, None, None, &token()));
384 responses.extend(gate(&Method::GET, None, None, &token()));
385 responses.push(text(StatusCode::NOT_FOUND, "nope"));
386
387 for res in responses {
388 for name in res.headers().keys() {
389 let lowered = name.as_str().to_ascii_lowercase();
390 assert!(
391 !lowered.starts_with("access-control-"),
392 "a control response carries {lowered}"
393 );
394 }
395 }
396 }
397
398 #[test]
399 fn hello_reports_a_protocol_range_and_the_aliases() {
400 let body = serde_json::to_string(&Hello {
401 daemon: env!("CARGO_PKG_VERSION"),
402 protocol: Protocol {
403 min: PROTOCOL_MIN,
404 max: PROTOCOL_MAX,
405 },
406 aliases: &["docs".to_string()],
407 suffix: "ssh-browser",
408 trips: 7,
409 })
410 .expect("serialises");
411 assert!(body.contains("\"min\":1"));
412 assert!(body.contains("\"max\":1"));
413 assert!(body.contains("\"aliases\":[\"docs\"]"));
414 assert!(body.contains("\"daemon\":\""));
415 // The cheap route carries it too, so a measurement does not have to pay for `hosts`.
416 assert!(body.contains("\"trips\":7"), "{body}");
417 }
418
419 /// A temporary file, named after the test so parallel runs cannot collide.
420 fn scratch(name: &str, contents: &str) -> std::path::PathBuf {
421 let path = std::env::temp_dir().join(format!("ssh-browser-token-{name}"));
422 std::fs::write(&path, contents).expect("a temp file");
423 path
424 }
425
426 /// The whole point of keeping it: a browser that has the token stays connected across a
427 /// restart, so nobody retypes sixty-four characters to get back to where they were.
428 #[test]
429 fn a_token_survives_the_round_trip_to_disk() {
430 let path = scratch("roundtrip", token().as_str());
431 let back = Token::from_disk(&path).expect("read back");
432 assert!(back.matches(token().as_str()));
433 let _ = std::fs::remove_file(&path);
434 }
435
436 /// Anything that is not a token is not accepted as one. Taking it would produce a daemon
437 /// whose token nothing can ever match — a locked door with no key rather than an error,
438 /// and one that only shows up as a 401 on every request.
439 #[test]
440 fn a_file_that_is_not_a_token_is_refused() {
441 let cases = [
442 ("empty", ""),
443 ("short", "0123456789abcdef"),
444 ("long", &"a".repeat(65) as &str),
445 ("not-hex", &"z".repeat(64)),
446 // Uppercase would compare unequal to everything this ever generates, so it is
447 // refused rather than quietly accepted and never matched.
448 ("uppercase", &"A".repeat(64)),
449 ("a sentence", "this file used to hold a token"),
450 ];
451 for (name, contents) in cases {
452 let path = scratch(name, contents);
453 assert!(
454 Token::from_disk(&path).is_none(),
455 "{name:?} should not have read as a token"
456 );
457 let _ = std::fs::remove_file(&path);
458 }
459 }
460
461 /// Written with a trailing newline by an editor, or by anyone who opened it to look.
462 #[test]
463 fn surrounding_whitespace_does_not_spoil_it() {
464 let path = scratch("whitespace", &format!("\n {}\t\n", token().as_str()));
465 assert!(
466 Token::from_disk(&path)
467 .expect("read back")
468 .matches(token().as_str())
469 );
470 let _ = std::fs::remove_file(&path);
471 }
472
473 #[test]
474 fn routes_are_named_after_the_prefix() {
475 assert_eq!(route_of("/_control/hello"), "hello");
476 assert_eq!(route_of("/_control/open"), "open");
477 assert_eq!(route_of("/not-control"), "");
478 }
479
480 /// The measured cases. An extension's fetch arrives as `none` with no `Origin`; a page
481 /// the daemon serves in the no-proxy fallback mode arrives as `same-origin`, which is
482 /// the hardest one because it shares an origin with the control API; anything from
483 /// elsewhere is `cross-site`. Absent is not a browser at all.
484 #[test]
485 fn a_page_is_told_apart_from_an_extension() {
486 assert!(!from_a_page(None));
487 assert!(!from_a_page(Some("none")));
488 for page in ["same-origin", "same-site", "cross-site"] {
489 assert!(from_a_page(Some(page)), "{page} is a page");
490 }
491 }
492
493 /// Refused before the token is even looked at, because it is the stronger statement:
494 /// a page holding a leaked token is the one case the token alone could not refuse.
495 #[test]
496 fn a_page_cannot_reach_the_control_api_even_with_the_right_token() {
497 let refusal = gate(
498 &Method::GET,
499 Some("same-origin"),
500 Some(token().as_str()),
501 &token(),
502 )
503 .expect("refused");
504 assert_eq!(refusal.status(), StatusCode::FORBIDDEN);
505 }
506}