trillium_websockets/lib.rs
1#![forbid(unsafe_code)]
2#![deny(
3 clippy::dbg_macro,
4 missing_copy_implementations,
5 rustdoc::missing_crate_level_docs,
6 missing_debug_implementations,
7 missing_docs,
8 nonstandard_style,
9 unused_qualifications
10)]
11
12//! # A websocket trillium handler
13//!
14//! There are three primary ways to use this crate
15//!
16//! ## With an async function that receives a [`WebSocketConn`]
17//!
18//! This is the simplest way to use trillium websockets, but does not
19//! provide any of the affordances that implementing the
20//! [`WebSocketHandler`] trait does. It is best for very simple websockets
21//! or for usages that require moving the WebSocketConn elsewhere in an
22//! application. The WebSocketConn is fully owned at this point, and will
23//! disconnect when dropped, not when the async function passed to
24//! `websocket` completes.
25//!
26//! ```
27//! use futures_lite::stream::StreamExt;
28//! use trillium_websockets::{Message, WebSocketConn, websocket};
29//!
30//! let handler = websocket(|mut conn: WebSocketConn| async move {
31//! while let Some(Ok(Message::Text(input))) = conn.next().await {
32//! conn.send_string(format!("received your message: {}", &input))
33//! .await;
34//! }
35//! });
36//! # // tests at tests/tests.rs for example simplicity
37//! ```
38//!
39//!
40//! ## Implementing [`WebSocketHandler`]
41//!
42//! [`WebSocketHandler`] provides support for sending outbound messages as a
43//! stream, and simplifies common patterns like executing async code on
44//! received messages.
45//!
46//! ## Using [`JsonWebSocketHandler`]
47//!
48//! [`JsonWebSocketHandler`] provides a thin serialization and
49//! deserialization layer on top of [`WebSocketHandler`] for this common
50//! use case. See the [`JsonWebSocketHandler`] documentation for example
51//! usage. In order to use this trait, the `json` cargo feature must be
52//! enabled.
53//!
54//! ## Origin checking
55//!
56//! Browsers do not apply CORS to websocket handshakes, so a page on any site can open a socket to
57//! any server, and the browser attaches the visitor's cookies to that handshake. RFC 6455 §10.2
58//! assigns the origin check to the server for exactly this reason; skipping it is the
59//! cross-site websocket hijacking vulnerability.
60//!
61//! By default this handler accepts a handshake only if its `Origin` names the same host as the
62//! request's `Host` or `:authority`, or if there is no `Origin` at all, which means the client is
63//! not a browser. Rejected handshakes receive a `403 Forbidden` and log which origin was refused.
64//!
65//! ```
66//! # use trillium_websockets::{WebSocket, WebSocketConn};
67//! # let handler = |_: WebSocketConn| async {};
68//! WebSocket::new(handler); // same-origin (default)
69//! //
70//! # let handler = |_: WebSocketConn| async {};
71//! WebSocket::new(handler).allow_origins(["https://app.example.com"]);
72//! # let handler = |_: WebSocketConn| async {};
73//! WebSocket::new(handler).allow_origin_fn(|origin| origin == Some("https://app.example.com"));
74//! # let handler = |_: WebSocketConn| async {};
75//! WebSocket::new(handler).allow_any_origin(); // opt out
76//! ```
77//!
78//! An application that serves its pages from one host and its sockets from another — say pages on
79//! `app.example.com` and sockets on `api.example.com` — must name the page origin with
80//! [`allow_origins`][WebSocket::allow_origins].
81//!
82//! ## Message size
83//!
84//! Inbound messages are assembled in memory before they reach a handler, up to tungstenite's
85//! default of 64 MiB per message. Applications that exchange small messages should lower that
86//! with [`with_protocol_config`][WebSocket::with_protocol_config].
87
88#[cfg(test)]
89#[doc = include_str!("../README.md")]
90mod readme {}
91
92mod direction;
93mod driver;
94mod origin;
95mod websocket_connection;
96mod websocket_handler;
97
98// Retained only so that code naming `trillium_websockets::async_tungstenite` keeps building;
99// remove it, and the async-tungstenite dependency, at the next breaking release.
100pub use async_tungstenite;
101use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
102use direction::{Direction, poll_next_direction};
103use origin::{OriginPolicy, OriginPredicate};
104use sha1::{Digest, Sha1};
105use std::{
106 net::IpAddr,
107 ops::{Deref, DerefMut},
108};
109use trillium::{
110 Conn, Handler, Info, KnownHeaderName,
111 KnownHeaderName::{
112 Connection, SecWebsocketAccept, SecWebsocketKey, SecWebsocketProtocol, SecWebsocketVersion,
113 Upgrade as UpgradeHeader,
114 },
115 Method, Status, Upgrade, Version,
116};
117pub use tungstenite::{
118 self, Message,
119 protocol::{Role, WebSocketConfig},
120};
121pub use websocket_connection::WebSocketConn;
122pub use websocket_handler::WebSocketHandler;
123
124const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
125
126#[derive(thiserror::Error, Debug)]
127#[non_exhaustive]
128/// An Error type that represents all exceptional conditions that can be encoutered in the operation
129/// of this crate
130pub enum Error {
131 #[error(transparent)]
132 /// an error in the underlying websocket implementation
133 WebSocket(#[from] tungstenite::Error),
134
135 #[cfg(feature = "json")]
136 #[error(transparent)]
137 /// an error in json serialization or deserialization
138 Json(#[from] serde_json::Error),
139}
140
141/// a Result type for this crate
142pub type Result<T = Message> = std::result::Result<T, Error>;
143
144#[cfg(feature = "json")]
145mod json;
146
147#[cfg(feature = "json")]
148pub use json::{JsonHandler, JsonWebSocketHandler, json_websocket};
149
150/// The trillium handler.
151/// See crate-level docs for example usage.
152#[derive(Debug)]
153pub struct WebSocket<H> {
154 handler: H,
155 protocols: Vec<String>,
156 config: Option<WebSocketConfig>,
157 required: bool,
158 origin_policy: OriginPolicy,
159}
160
161impl<H> Deref for WebSocket<H> {
162 type Target = H;
163
164 fn deref(&self) -> &Self::Target {
165 &self.handler
166 }
167}
168
169impl<H> DerefMut for WebSocket<H> {
170 fn deref_mut(&mut self) -> &mut Self::Target {
171 &mut self.handler
172 }
173}
174
175/// Builds a new trillium handler from the provided
176/// WebSocketHandler. Alias for [`WebSocket::new`]
177pub fn websocket<H>(websocket_handler: H) -> WebSocket<H>
178where
179 H: WebSocketHandler,
180{
181 WebSocket::new(websocket_handler)
182}
183
184impl<H> WebSocket<H>
185where
186 H: WebSocketHandler,
187{
188 async fn run_h1(&self, mut conn: Conn) -> Conn {
189 if !upgrade_requested(&conn) {
190 if self.required {
191 return conn.with_status(Status::UpgradeRequired).halt();
192 } else {
193 return conn;
194 }
195 }
196
197 if !self.origin_policy.allows(&conn) {
198 return reject_origin(conn);
199 }
200
201 if !supported_websocket_version(&conn) {
202 return reject_unsupported_version(conn);
203 }
204
205 let websocket_peer_ip = WebsocketPeerIp(conn.peer_ip());
206
207 let Some(sec_websocket_key) = conn.request_headers().get_str(SecWebsocketKey) else {
208 return conn.with_status(Status::BadRequest).halt();
209 };
210 let sec_websocket_accept = websocket_accept_hash(sec_websocket_key);
211
212 let protocol = websocket_protocol(&conn, &self.protocols);
213
214 let headers = conn.response_headers_mut();
215
216 headers.extend([
217 (UpgradeHeader, "websocket"),
218 (Connection, "Upgrade"),
219 (SecWebsocketVersion, "13"),
220 ]);
221
222 headers.insert(SecWebsocketAccept, sec_websocket_accept);
223
224 if let Some(protocol) = protocol {
225 headers.insert(SecWebsocketProtocol, protocol);
226 }
227
228 conn.halt()
229 .with_state(websocket_peer_ip)
230 .with_state(IsWebsocket)
231 .with_status(Status::SwitchingProtocols)
232 }
233
234 /// Build a new WebSocket with an async handler function that
235 /// receives a [`WebSocketConn`]
236 pub fn new(handler: H) -> Self {
237 Self {
238 handler,
239 protocols: Default::default(),
240 config: None,
241 required: false,
242 origin_policy: OriginPolicy::default(),
243 }
244 }
245
246 /// Accept handshakes only from pages on these origins.
247 ///
248 /// ```
249 /// # use trillium_websockets::{WebSocket, WebSocketConn};
250 /// # let websocket = WebSocket::new(|_: WebSocketConn| async {});
251 /// websocket.allow_origins(["https://app.example.com", "https://admin.example.com"]);
252 /// ```
253 ///
254 /// Origins are compared exactly, after normalizing default ports and idn hosts. Nothing is
255 /// matched by prefix or suffix, because an allowed origin of `example.com` matched by suffix
256 /// also admits `evil-example.com`. For a family of subdomains, use
257 /// [`allow_origin_fn`][Self::allow_origin_fn].
258 ///
259 /// A request with no `Origin` header is allowed, as it did not come from a browser.
260 ///
261 /// # Panics
262 ///
263 /// Panics if any of the provided strings is not a url with a scheme and a host, or if it
264 /// carries a path, query, fragment, or userinfo.
265 pub fn allow_origins<'a>(mut self, origins: impl IntoIterator<Item = &'a str>) -> Self {
266 self.origin_policy = OriginPolicy::list(origins);
267 self
268 }
269
270 /// Accept handshakes for which this predicate returns true.
271 ///
272 /// The argument is the raw `Origin` header, so `None` (a non-browser client) stays
273 /// distinguishable from `Some("null")` (a sandboxed iframe or a `file://` page, which is
274 /// attacker-reachable and should generally be rejected).
275 ///
276 /// ```
277 /// # use trillium_websockets::{WebSocket, WebSocketConn};
278 /// # let websocket = WebSocket::new(|_: WebSocketConn| async {});
279 /// websocket.allow_origin_fn(|origin| match origin {
280 /// None => true,
281 /// Some(origin) => origin
282 /// .strip_prefix("https://")
283 /// .is_some_and(|host| host == "example.com" || host.ends_with(".example.com")),
284 /// });
285 /// ```
286 pub fn allow_origin_fn<F>(mut self, predicate: F) -> Self
287 where
288 F: Fn(Option<&str>) -> bool + Send + Sync + 'static,
289 {
290 self.origin_policy = OriginPolicy::Predicate(OriginPredicate::from(predicate));
291 self
292 }
293
294 /// Accept handshakes from any origin, disabling the same-origin default.
295 ///
296 /// Any page on the web can then open a socket to this handler and act with the browser's
297 /// ambient authority — the visitor's cookies are attached to the handshake. Only do this if
298 /// the socket is either unauthenticated or authenticated by something the page cannot
299 /// replay, such as a token the client sends in its first message.
300 pub fn allow_any_origin(mut self) -> Self {
301 self.origin_policy = OriginPolicy::Any;
302 self
303 }
304
305 /// `protocols` is a sequence of known protocols. On successful handshake,
306 /// the returned response headers contain the first protocol in this list
307 /// which the server also knows.
308 pub fn with_protocols(self, protocols: &[&str]) -> Self {
309 Self {
310 protocols: protocols.iter().map(ToString::to_string).collect(),
311 ..self
312 }
313 }
314
315 /// configure the websocket protocol
316 ///
317 /// `read_buffer_size` is the most that is read from the socket at once, allocated once per
318 /// connection. Without a config it is 8 KiB; [`WebSocketConfig::default`] carries
319 /// tungstenite's 128 KiB, so set it explicitly when starting from the default.
320 /// `write_buffer_size` is how much fed output can accumulate before
321 /// [`WebSocketConn::feed`] waits for it to be written.
322 pub fn with_protocol_config(self, config: WebSocketConfig) -> Self {
323 Self {
324 config: Some(config),
325 ..self
326 }
327 }
328
329 /// configure this handler to halt and send back a [`426 Upgrade
330 /// Required`][Status::UpgradeRequired] if a websocket cannot be negotiated
331 pub fn required(mut self) -> Self {
332 self.required = true;
333 self
334 }
335}
336
337struct IsWebsocket;
338
339#[cfg(test)]
340mod tests;
341
342// this is a workaround for the fact that Upgrade is a public struct,
343// so adding peer_ip to that struct would be a breaking change. We
344// stash a copy in state for now.
345struct WebsocketPeerIp(Option<IpAddr>);
346
347impl<H> Handler for WebSocket<H>
348where
349 H: WebSocketHandler,
350{
351 async fn run(&self, mut conn: Conn) -> Conn {
352 match conn.http_version() {
353 Version::Http1_0 | Version::Http1_1 => self.run_h1(conn).await,
354 // Extended-CONNECT bootstrap of WebSockets — RFC 8441 (h2) and RFC 9220 (h3) define
355 // the same shape: `:method = CONNECT`, `:protocol = websocket`, no SHA1/Key/Accept
356 // handshake. The server replies with status 200 and the stream stays open as a
357 // bidirectional byte channel carrying WebSocket frames.
358 Version::Http2 | Version::Http3 => {
359 if extended_connect_websocket_request(&conn) {
360 if !self.origin_policy.allows(&conn) {
361 return reject_origin(conn);
362 }
363
364 if !supported_websocket_version(&conn) {
365 return reject_unsupported_version(conn);
366 }
367
368 let websocket_peer_ip = WebsocketPeerIp(conn.peer_ip());
369 let protocol = websocket_protocol(&conn, &self.protocols);
370
371 if let Some(protocol) = protocol {
372 conn.response_headers_mut()
373 .insert(SecWebsocketProtocol, protocol);
374 }
375
376 conn.halt()
377 .with_state(websocket_peer_ip)
378 .with_state(IsWebsocket)
379 .with_status(Status::Ok)
380 } else if self.required {
381 conn.with_status(Status::UpgradeRequired).halt()
382 } else {
383 conn
384 }
385 }
386 _ => {
387 if self.required {
388 conn.with_status(Status::UpgradeRequired).halt()
389 } else {
390 conn
391 }
392 }
393 }
394 }
395
396 async fn init(&mut self, info: &mut Info) {
397 // Required for h2 (RFC 8441 §3) and h3 (RFC 9220 §3) clients to attempt the extended
398 // CONNECT bootstrap of WebSockets. Harmless on h1.
399 info.config_mut().set_extended_connect_enabled(true);
400 }
401
402 fn has_upgrade(&self, upgrade: &Upgrade) -> bool {
403 upgrade.state().contains::<IsWebsocket>()
404 }
405
406 async fn upgrade(&self, mut upgrade: Upgrade) {
407 let peer_ip = upgrade
408 .state_mut()
409 .take::<WebsocketPeerIp>()
410 .and_then(|i| i.0);
411 let mut conn = WebSocketConn::new(upgrade, self.config, Role::Server).await;
412 conn.set_peer_ip(peer_ip);
413
414 let Some((mut conn, outbound)) = self.handler.connect(conn).await else {
415 return;
416 };
417
418 let mut outbound = std::pin::pin!(outbound);
419 loop {
420 let next = futures_lite::future::poll_fn(|cx| {
421 poll_next_direction(&mut conn, outbound.as_mut(), cx)
422 })
423 .await;
424
425 let Some(message) = next else { break };
426 match message {
427 Direction::Inbound(Ok(Message::Close(close_frame))) => {
428 self.handler.disconnect(&mut conn, close_frame).await;
429 break;
430 }
431
432 Direction::Inbound(Ok(message)) => {
433 self.handler.inbound(message, &mut conn).await;
434 }
435
436 Direction::Outbound(message) => {
437 if let Err(e) = self.handler.send(message, &mut conn).await {
438 log::warn!("outbound websocket error: {:?}", e);
439 break;
440 }
441 }
442
443 _ => {
444 self.handler.disconnect(&mut conn, None).await;
445 break;
446 }
447 }
448 }
449
450 if let Some(err) = conn.close().await.err() {
451 log::warn!("websocket close error: {:?}", err);
452 };
453 }
454}
455
456fn websocket_protocol(conn: &Conn, protocols: &[String]) -> Option<String> {
457 conn.request_headers()
458 .token_iter(SecWebsocketProtocol)
459 .find(|req_p| protocols.iter().any(|x| x == req_p))
460 .map(str::to_owned)
461}
462
463fn connection_is_upgrade(conn: &Conn) -> bool {
464 conn.request_headers()
465 .token_iter(Connection)
466 .any(|c| c.eq_ignore_ascii_case("upgrade"))
467}
468
469fn upgrade_to_websocket(conn: &Conn) -> bool {
470 conn.request_headers()
471 .eq_ignore_ascii_case(UpgradeHeader, "websocket")
472}
473
474fn supported_websocket_version(conn: &Conn) -> bool {
475 conn.request_headers().get_str(SecWebsocketVersion) == Some("13")
476}
477
478fn reject_origin(conn: Conn) -> Conn {
479 log::warn!(
480 "rejecting websocket handshake from origin {:?} for authority {:?}. If this is expected, \
481 configure the origins that may open a websocket with `WebSocket::allow_origins([..])`, \
482 `WebSocket::allow_origin_fn(..)`, or `WebSocket::allow_any_origin()`.",
483 conn.request_headers().get_str(KnownHeaderName::Origin),
484 conn.host()
485 );
486
487 conn.with_status(Status::Forbidden).halt()
488}
489
490fn reject_unsupported_version(conn: Conn) -> Conn {
491 conn.with_status(Status::UpgradeRequired)
492 .with_response_header(SecWebsocketVersion, "13")
493 .halt()
494}
495
496fn upgrade_requested(conn: &Conn) -> bool {
497 conn.method() == Method::Get
498 && conn.http_version() == Version::Http1_1
499 && connection_is_upgrade(conn)
500 && upgrade_to_websocket(conn)
501}
502
503/// Detect a WebSocket bootstrap over extended CONNECT (RFC 8441 for h2, RFC 9220 for h3).
504///
505/// The peer must use `CONNECT` and carry a `:protocol` pseudo-header equal to "websocket"
506/// (case-insensitive per the RFCs).
507fn extended_connect_websocket_request(conn: &Conn) -> bool {
508 if conn.method() != Method::Connect {
509 return false;
510 }
511 let inner: &trillium_http::Conn<Box<dyn trillium::Transport>> = conn.as_ref();
512 inner
513 .protocol()
514 .is_some_and(|p| p.eq_ignore_ascii_case("websocket"))
515}
516
517/// Generate a random key suitable for Sec-WebSocket-Key
518pub fn websocket_key() -> String {
519 BASE64.encode(fastrand::u128(..).to_ne_bytes())
520}
521
522/// Generate the expected Sec-WebSocket-Accept hash from the Sec-WebSocket-Key
523pub fn websocket_accept_hash(websocket_key: &str) -> String {
524 let hash = Sha1::new()
525 .chain_update(websocket_key)
526 .chain_update(WEBSOCKET_GUID)
527 .finalize();
528 BASE64.encode(&hash[..])
529}