anvilsign in

collin/browser-terminal-extension

1//! Handshake authorization: Origin allowlist, Host validation, token compare.
2//!
3//! Why both an origin check and a token:
4//!
5//! - `Origin` is set by the browser and cannot be forged from page JS, so it is
6//! a real defense against a hostile *web page* (WebSocket has no CORS — any
7//! page can open a socket to 127.0.0.1). It is useless against a local
8//! process, which can set any header it likes.
9//! - The token defends against local processes belonging to *other* uids, via
10//! the 0600 file mode. It does nothing against a hostile page, which would
11//! happily send a token it stole.
12//!
13//! Neither is sufficient alone. Both are required.
14
15use subtle::ConstantTimeEq;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Denied {
19 /// No Origin header at all (a non-browser client, or a browser context we
20 /// don't understand). Rejected: pairing is keyed on origin.
21 MissingOrigin,
22 /// Origin is well-formed but the user has not paired it.
23 UnpairedOrigin,
24 /// Host header points somewhere other than loopback — the signature of a
25 /// DNS-rebinding attempt, where `evil.com` resolves to 127.0.0.1.
26 ForeignHost,
27 /// Wrong token in the auth frame.
28 BadToken,
29 /// No auth frame arrived inside the deadline.
30 AuthTimeout,
31 /// Auth frame was not the expected shape.
32 MalformedAuth,
33 /// Too many recent failures.
34 RateLimited,
35 /// The request never was a WebSocket upgrade — a plain HTTP request, a TLS
36 /// ClientHello, or a proxy in the way. Nothing to do with auth.
37 NotAWebSocketUpgrade,
38 /// The client opened with a TLS ClientHello but we have no TLS identity.
39 TlsAttempted,
40 /// TLS negotiation failed — usually the browser refusing our self-signed
41 /// certificate because no exception has been added yet.
42 TlsHandshakeFailed,
43}
44
45impl Denied {
46 /// HTTP status used when we can still reject during the handshake.
47 pub fn status(self) -> u16 {
48 match self {
49 Denied::RateLimited => 429,
50 Denied::NotAWebSocketUpgrade => 400,
51 Denied::TlsAttempted => 400,
52 Denied::TlsHandshakeFailed => 400,
53 _ => 403,
54 }
55 }
56
57 pub fn reason(self) -> &'static str {
58 match self {
59 Denied::MissingOrigin => "missing Origin header",
60 Denied::UnpairedOrigin => "origin not paired",
61 Denied::ForeignHost => "Host header is not loopback",
62 Denied::BadToken => "invalid token",
63 Denied::AuthTimeout => "auth timeout",
64 Denied::MalformedAuth => "malformed auth frame",
65 Denied::RateLimited => "too many failed attempts",
66 Denied::NotAWebSocketUpgrade => "not a WebSocket upgrade request",
67 Denied::TlsAttempted => "client spoke TLS but no certificate is configured",
68 Denied::TlsHandshakeFailed => "TLS handshake failed (certificate not trusted yet?)",
69 }
70 }
71}
72
73/// Hostnames we accept in the `Host` header. Anything else means the client
74/// reached us under a name that isn't loopback.
75const LOOPBACK_HOSTS: &[&str] = &["127.0.0.1", "localhost", "[::1]", "::1"];
76
77pub fn host_is_loopback(host_header: &str) -> bool {
78 let h = host_header.trim().to_ascii_lowercase();
79 // Strip the port. Bracketed IPv6 literals need care: "[::1]:7681".
80 let hostname = if let Some(rest) = h.strip_prefix('[') {
81 match rest.split_once(']') {
82 Some((inner, _)) => format!("[{inner}]"),
83 None => return false,
84 }
85 } else {
86 h.split(':').next().unwrap_or("").to_string()
87 };
88 LOOPBACK_HOSTS.contains(&hostname.as_str())
89}
90
91pub fn origin_is_paired(origin: &str, paired: &[String]) -> bool {
92 let o = origin.trim().to_ascii_lowercase();
93 if o.is_empty() || o == "null" {
94 return false;
95 }
96 paired.iter().any(|p| p == &o)
97}
98
99/// Constant-time token comparison. A byte-by-byte `==` leaks the length of the
100/// matching prefix through timing, which is enough to recover a token given
101/// enough attempts from a local process.
102pub fn token_matches(presented: &str, expected: &str) -> bool {
103 let a = presented.trim().as_bytes();
104 let b = expected.trim().as_bytes();
105 if a.len() != b.len() {
106 // Length is not secret-dependent in a useful way here (the token is a
107 // fixed 64 hex chars), but still burn a comparison so the fast path
108 // isn't obviously distinguishable.
109 let _ = b.ct_eq(b);
110 return false;
111 }
112 a.ct_eq(b).into()
113}
114
115/// Validate the parts of the request we can see before the upgrade completes.
116pub fn check_handshake(
117 origin: Option<&str>,
118 host: Option<&str>,
119 paired: &[String],
120) -> Result<(), Denied> {
121 // Host first: a rebinding attempt should not even reach origin logic.
122 match host {
123 Some(h) if host_is_loopback(h) => {}
124 _ => return Err(Denied::ForeignHost),
125 }
126 match origin {
127 None => Err(Denied::MissingOrigin),
128 Some(o) if origin_is_paired(o, paired) => Ok(()),
129 Some(_) => Err(Denied::UnpairedOrigin),
130 }
131}
132
133/// Parse the first frame, which must be `{"type":"auth","token":"..."}`.
134///
135/// Note there is deliberately no way to authenticate via the URL query string.
136/// Query strings leak into logs, crash dumps and devtools history, and the
137/// browser WebSocket API makes it tempting to put the token there.
138pub fn check_auth_frame(text: &str, expected_token: &str) -> Result<(), Denied> {
139 let v: serde_json::Value = serde_json::from_str(text).map_err(|_| Denied::MalformedAuth)?;
140 if v.get("type").and_then(|t| t.as_str()) != Some("auth") {
141 return Err(Denied::MalformedAuth);
142 }
143 let Some(tok) = v.get("token").and_then(|t| t.as_str()) else {
144 return Err(Denied::MalformedAuth);
145 };
146 if token_matches(tok, expected_token) {
147 Ok(())
148 } else {
149 Err(Denied::BadToken)
150 }
151}