Skip to main content

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}