h2ts_server/handshake.rs
1//! Server-side WebSocket handshake (RFC 6455).
2//!
3//! It's small: parse the upgrade headers, compute `Sec-WebSocket-Accept` (SHA-1
4//! of the client key plus the RFC 6455 magic GUID, base64-encoded), reply `101`,
5//! and hand back hyper's upgraded connection as a raw byte stream. The framing on
6//! top of that stream is done by [`bridge`](crate::bridge) using wslay.
7
8use std::fmt;
9use std::future::Future;
10
11use base64::Engine as _;
12use bytes::Bytes;
13use http::header::{
14 HeaderName, HeaderValue, CONNECTION, ORIGIN, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY,
15 SEC_WEBSOCKET_PROTOCOL, UPGRADE,
16};
17use http_body_util::Empty;
18use hyper::upgrade::Upgraded;
19use hyper::{Request, Response, StatusCode};
20use hyper_util::rt::TokioIo;
21use sha1::{Digest, Sha1};
22
23/// RFC 6455 §4.2.2: the magic GUID appended to the client key before hashing.
24const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
25
26/// The subprotocol h2ts clients offer by default. [`accept`] echoes it when the
27/// client offered it and the handler didn't choose another (see [`accept_with`]).
28pub const DEFAULT_SUBPROTOCOL: &str = "h2ts";
29
30/// Options controlling how the handshake picks a subprotocol to echo when the
31/// handler's selector declines (see [`accept_with_options`]).
32#[derive(Debug, Clone, Default)]
33pub struct AcceptOptions {
34 /// When the selector returns `None` and the client did **not** offer
35 /// [`DEFAULT_SUBPROTOCOL`] (`h2ts`), accept the client's first offered
36 /// subprotocol (or, if it offered none, complete with no subprotocol)
37 /// instead of **rejecting** the handshake.
38 ///
39 /// Off by default: a client that speaks neither `h2ts` nor a
40 /// handler-selected protocol is rejected with
41 /// [`WebSocketError::UnsupportedSubprotocol`]. Turn this on for a
42 /// codec-agnostic tunnel that accepts whatever framing the client offered
43 /// (e.g. a raw/binary tunnel, or a websockify-style `binary` client).
44 pub allow_implicit_codec: bool,
45 /// Opt-in `Origin` allowlist (Cross-Site WebSocket Hijacking defence). When
46 /// `Some`, only handshakes whose `Origin` header matches one of these entries
47 /// (ASCII case-insensitive, e.g. `https://app.example.com`) are accepted;
48 /// everything else — including a *missing* `Origin` — is rejected with a `403`
49 /// ([`WebSocketError::ForbiddenOrigin`]).
50 ///
51 /// `None` (the default) accepts any origin. This mirrors nginx, which does no
52 /// `Origin` validation by default and leaves it as an opt-in allowlist; a
53 /// browser always sends `Origin`, so a legitimate browser client is unaffected.
54 pub allowed_origins: Option<Vec<String>>,
55}
56
57/// The upgraded WebSocket connection, presented as a raw tokio byte stream
58/// (`AsyncRead + AsyncWrite`). Hand it to [`bridge`](crate::bridge),
59/// [`serve_h2`](crate::serve_h2), or [`WsByteStream`](crate::WsByteStream).
60pub type UpgradedIo = TokioIo<Upgraded>;
61
62/// Errors from the WebSocket handshake.
63#[derive(Debug)]
64pub enum WebSocketError {
65 /// The request was not a valid WebSocket upgrade (missing/invalid
66 /// `Upgrade` / `Connection` / `Sec-WebSocket-Key`).
67 NotUpgradeRequest,
68 /// The client offered no subprotocol the server accepts: it didn't offer
69 /// [`DEFAULT_SUBPROTOCOL`] (`h2ts`), the handler's selector declined, and
70 /// [`AcceptOptions::allow_implicit_codec`] was off. Reject it with a `400`
71 /// (see [`WebSocketError::rejection_response`]).
72 UnsupportedSubprotocol,
73 /// The request's `Origin` is not in the configured
74 /// [`AcceptOptions::allowed_origins`] allowlist (or is missing). Reject it
75 /// with a `403` (CSWSH defence).
76 ForbiddenOrigin,
77 /// hyper failed to upgrade the connection after the `101` was sent.
78 Upgrade(hyper::Error),
79}
80
81impl WebSocketError {
82 /// The HTTP response to reject this handshake with, for the pre-upgrade
83 /// errors returned by [`accept`] / [`accept_with`] / [`accept_with_options`]:
84 /// `426 Upgrade Required` for a non-WebSocket request, `400 Bad Request` for
85 /// an unsupported subprotocol. (An [`Upgrade`](WebSocketError::Upgrade) error
86 /// happens *after* the `101` and has no meaningful rejection response; it maps
87 /// to `500` for completeness.)
88 ///
89 /// ```ignore
90 /// let (response, ws_fut) = match h2ts_server::accept(&mut req) {
91 /// Ok(pair) => pair,
92 /// Err(err) => return Ok(err.rejection_response()), // send the 4xx back
93 /// };
94 /// ```
95 pub fn rejection_response(&self) -> Response<Empty<Bytes>> {
96 let status = match self {
97 WebSocketError::NotUpgradeRequest => StatusCode::UPGRADE_REQUIRED,
98 WebSocketError::UnsupportedSubprotocol => StatusCode::BAD_REQUEST,
99 WebSocketError::ForbiddenOrigin => StatusCode::FORBIDDEN,
100 WebSocketError::Upgrade(_) => StatusCode::INTERNAL_SERVER_ERROR,
101 };
102 Response::builder()
103 .status(status)
104 .body(Empty::new())
105 .expect("static rejection response is well-formed")
106 }
107}
108
109impl fmt::Display for WebSocketError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 WebSocketError::NotUpgradeRequest => f.write_str("not a WebSocket upgrade request"),
113 WebSocketError::UnsupportedSubprotocol => {
114 f.write_str("client offered no supported subprotocol")
115 }
116 WebSocketError::ForbiddenOrigin => f.write_str("origin not allowed"),
117 WebSocketError::Upgrade(e) => write!(f, "WebSocket upgrade failed: {e}"),
118 }
119 }
120}
121
122impl std::error::Error for WebSocketError {
123 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124 match self {
125 WebSocketError::Upgrade(e) => Some(e),
126 WebSocketError::NotUpgradeRequest
127 | WebSocketError::UnsupportedSubprotocol
128 | WebSocketError::ForbiddenOrigin => None,
129 }
130 }
131}
132
133/// Whether `header` is present and lists `needle` as one of its comma-separated,
134/// case-insensitive tokens.
135fn header_lists<B>(request: &Request<B>, header: HeaderName, needle: &str) -> bool {
136 request
137 .headers()
138 .get(header)
139 .and_then(|v| v.to_str().ok())
140 .map(|list| {
141 list.split(',')
142 .any(|t| t.trim().eq_ignore_ascii_case(needle))
143 })
144 .unwrap_or(false)
145}
146
147/// Whether `request` is a WebSocket upgrade: `Upgrade: websocket`,
148/// `Connection: upgrade`, and a `Sec-WebSocket-Key`.
149pub fn is_upgrade_request<B>(request: &Request<B>) -> bool {
150 header_lists(request, UPGRADE, "websocket")
151 && header_lists(request, CONNECTION, "upgrade")
152 && request.headers().contains_key(SEC_WEBSOCKET_KEY)
153}
154
155/// The WebSocket subprotocols the client offered, in order, whitespace-trimmed;
156/// empty if it offered none. Gives a handler full visibility into the offer so
157/// it can pass one to [`accept_with`].
158pub fn offered_protocols<B>(request: &Request<B>) -> Vec<&str> {
159 request
160 .headers()
161 .get(SEC_WEBSOCKET_PROTOCOL)
162 .and_then(|v| v.to_str().ok())
163 .map(|list| {
164 list.split(',')
165 .map(str::trim)
166 .filter(|s| !s.is_empty())
167 .collect()
168 })
169 .unwrap_or_default()
170}
171
172/// The outcome of the default subprotocol fallback, when the handler's selector
173/// declined.
174#[derive(Debug, PartialEq, Eq)]
175enum Fallback {
176 /// Complete the handshake, echoing this subprotocol (`Some`) or none (`None`).
177 Accept(Option<String>),
178 /// Reject the handshake: the client offered no acceptable subprotocol and
179 /// implicit codecs are disabled.
180 Reject,
181}
182
183/// The subprotocol decision when the handler's selector declined.
184///
185/// Prefers [`DEFAULT_SUBPROTOCOL`] (`h2ts`) whenever the client offered it (in
186/// its exact offered casing). Otherwise, with `allow_implicit_codec`, accept the
187/// client's first offered subprotocol (or none, if it offered nothing); without
188/// it, [`Fallback::Reject`] — we don't commit to a codec we don't understand.
189fn fallback_subprotocol(offered: &[&str], allow_implicit_codec: bool) -> Fallback {
190 if let Some(p) = offered
191 .iter()
192 .find(|p| p.eq_ignore_ascii_case(DEFAULT_SUBPROTOCOL))
193 {
194 return Fallback::Accept(Some(p.to_string()));
195 }
196 if allow_implicit_codec {
197 return Fallback::Accept(offered.first().map(|p| p.to_string()));
198 }
199 Fallback::Reject
200}
201
202/// Compute the `Sec-WebSocket-Accept` value for a client `Sec-WebSocket-Key`.
203fn accept_key(key: &[u8]) -> String {
204 let mut hasher = Sha1::new();
205 hasher.update(key);
206 hasher.update(WS_GUID);
207 base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
208}
209
210/// Accept a WebSocket upgrade, choosing which offered subprotocol to echo, with
211/// explicit [`AcceptOptions`].
212///
213/// `select` is handed the full list of subprotocols the client offered (the same
214/// thing [`offered_protocols`] returns) and returns the one to echo in the `101`,
215/// or `None` to decline. When it declines, [`DEFAULT_SUBPROTOCOL`] (`h2ts`) is
216/// echoed if the client offered it; otherwise the handshake is **rejected** with
217/// [`WebSocketError::UnsupportedSubprotocol`] — unless
218/// [`AcceptOptions::allow_implicit_codec`] is set, which instead accepts the
219/// client's first offered codec (or completes with none if it offered nothing).
220/// Turn a rejection into a `4xx` to send back via
221/// [`WebSocketError::rejection_response`].
222///
223/// Per RFC 6455 a client that offered subprotocols will *fail* the connection if
224/// the server echoes one it did not offer, so `select` should return a member of
225/// the offered list (or `None`).
226///
227/// See [`accept_with`] (default options) and [`accept`] (the common case).
228// The returned `impl Future` can't be aliased away, so the tuple reads as
229// "complex"; it's just (response, upgrade-future).
230#[allow(clippy::type_complexity)]
231pub fn accept_with_options<B, F>(
232 request: &mut Request<B>,
233 select: F,
234 options: AcceptOptions,
235) -> Result<
236 (
237 Response<Empty<Bytes>>,
238 impl Future<Output = Result<UpgradedIo, WebSocketError>>,
239 ),
240 WebSocketError,
241>
242where
243 F: FnOnce(&[&str]) -> Option<String>,
244{
245 if !is_upgrade_request(request) {
246 return Err(WebSocketError::NotUpgradeRequest);
247 }
248
249 // Opt-in Origin allowlist (CSWSH defence). Off by default (nginx-style): only
250 // enforced when the caller configured `allowed_origins`.
251 if let Some(allowed) = &options.allowed_origins {
252 let origin = request.headers().get(ORIGIN).and_then(|v| v.to_str().ok());
253 let ok = origin.is_some_and(|o| allowed.iter().any(|a| a.eq_ignore_ascii_case(o)));
254 if !ok {
255 return Err(WebSocketError::ForbiddenOrigin);
256 }
257 }
258 // Present because `is_upgrade_request` checked for it.
259 let key = request
260 .headers()
261 .get(SEC_WEBSOCKET_KEY)
262 .ok_or(WebSocketError::NotUpgradeRequest)?;
263 let accept_value = accept_key(key.as_bytes());
264
265 // Let the handler pick from the offered list; otherwise apply the default
266 // fallback (h2ts if offered, then optionally the first offered codec, else
267 // reject).
268 let offered = offered_protocols(request);
269 let decision = match select(&offered) {
270 // Never echo a subprotocol the client did not offer (RFC 6455 §4.2.2 — the
271 // client fails the connection if we do). A selection outside the offered
272 // list is treated as a decline and falls back to the default policy.
273 Some(proto) if offered.iter().any(|o| o.eq_ignore_ascii_case(&proto)) => {
274 Fallback::Accept(Some(proto))
275 }
276 _ => fallback_subprotocol(&offered, options.allow_implicit_codec),
277 };
278 drop(offered); // release the immutable borrow before upgrading below
279 let chosen = match decision {
280 Fallback::Accept(proto) => proto,
281 Fallback::Reject => return Err(WebSocketError::UnsupportedSubprotocol),
282 };
283
284 // base64 output is always valid header-value ASCII, so this never fails.
285 let accept_header = HeaderValue::from_str(&accept_value).expect("base64 is valid header ASCII");
286 let mut response = Response::builder()
287 .status(StatusCode::SWITCHING_PROTOCOLS)
288 .header(CONNECTION, HeaderValue::from_static("Upgrade"))
289 .header(UPGRADE, HeaderValue::from_static("websocket"))
290 .header(SEC_WEBSOCKET_ACCEPT, accept_header)
291 .body(Empty::<Bytes>::new())
292 .expect("static 101 response is well-formed");
293 if let Some(proto) = chosen {
294 // Skip a subprotocol that isn't a valid header value rather than fail.
295 if let Ok(value) = HeaderValue::from_str(&proto) {
296 response.headers_mut().insert(SEC_WEBSOCKET_PROTOCOL, value);
297 }
298 }
299
300 // Capture the upgrade future now; it resolves once the 101 is flushed and
301 // hyper hands over the connection.
302 let on_upgrade = hyper::upgrade::on(&mut *request);
303 let fut = async move {
304 let upgraded = on_upgrade.await.map_err(WebSocketError::Upgrade)?;
305 Ok(TokioIo::new(upgraded))
306 };
307 Ok((response, fut))
308}
309
310/// Accept a WebSocket upgrade, choosing which offered subprotocol to echo.
311///
312/// Like [`accept_with_options`] with the default [`AcceptOptions`]: when `select`
313/// declines, [`DEFAULT_SUBPROTOCOL`] (`h2ts`) is echoed if the client offered it;
314/// otherwise the handshake is **rejected**
315/// ([`WebSocketError::UnsupportedSubprotocol`]). Use [`accept_with_options`] with
316/// `allow_implicit_codec` to instead accept the client's first offered codec.
317///
318/// ```ignore
319/// // Prefer "chat" if the client offered it, otherwise fall back to h2ts.
320/// let (response, ws_fut) = h2ts_server::accept_with(&mut req, |offered| {
321/// offered.iter().find(|p| p.eq_ignore_ascii_case("chat")).map(|p| p.to_string())
322/// })?;
323/// ```
324#[allow(clippy::type_complexity)]
325pub fn accept_with<B, F>(
326 request: &mut Request<B>,
327 select: F,
328) -> Result<
329 (
330 Response<Empty<Bytes>>,
331 impl Future<Output = Result<UpgradedIo, WebSocketError>>,
332 ),
333 WebSocketError,
334>
335where
336 F: FnOnce(&[&str]) -> Option<String>,
337{
338 accept_with_options(request, select, AcceptOptions::default())
339}
340
341/// Accept a WebSocket upgrade, requiring the [`DEFAULT_SUBPROTOCOL`] (`h2ts`).
342///
343/// Echoes `h2ts` when the client offered it; a client that doesn't offer `h2ts`
344/// is **rejected** with [`WebSocketError::UnsupportedSubprotocol`] (send
345/// [`rejection_response`](WebSocketError::rejection_response) back as a `400`).
346/// Use [`accept_with`] to select a different offered subprotocol, or
347/// [`accept_with_options`] with `allow_implicit_codec` to accept any codec.
348///
349/// On success, returns the `101 Switching Protocols` response to send back
350/// immediately, plus a future that resolves to the upgraded connection as a byte
351/// stream ([`UpgradedIo`]). Drive the response through your framework; spawn the
352/// future and hand the stream to [`bridge`](crate::bridge) or
353/// [`serve_h2`](crate::serve_h2). Control frames (ping/close) are handled
354/// downstream by wslay.
355///
356/// The outer HTTP/1 connection must be served with upgrades enabled
357/// (`http1::Builder::serve_connection(..).with_upgrades()`).
358#[allow(clippy::type_complexity)]
359pub fn accept<B>(
360 request: &mut Request<B>,
361) -> Result<
362 (
363 Response<Empty<Bytes>>,
364 impl Future<Output = Result<UpgradedIo, WebSocketError>>,
365 ),
366 WebSocketError,
367> {
368 accept_with(request, |_offered| None)
369}