| 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 | |
| 15 | use subtle::ConstantTimeEq; |
| 16 | |
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 18 | pub 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 | |
| 45 | impl 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. |
| 75 | const LOOPBACK_HOSTS: &[&str] = &["127.0.0.1", "localhost", "[::1]", "::1"]; |
| 76 | |
| 77 | pub 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 | |
| 91 | pub 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. |
| 102 | pub 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. |
| 116 | pub 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. |
| 138 | pub 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 | } |