diff --git a/engine/packages/guard-core/src/proxy_service.rs b/engine/packages/guard-core/src/proxy_service.rs index f54bfe8f64..223880451a 100644 --- a/engine/packages/guard-core/src/proxy_service.rs +++ b/engine/packages/guard-core/src/proxy_service.rs @@ -43,6 +43,24 @@ pub const X_RIVET_ERROR: HeaderName = HeaderName::from_static("x-rivet-error"); const PROXY_STATE_CACHE_TTL: Duration = Duration::from_secs(60 * 60); // 1 hour const WEBSOCKET_CLOSE_LINGER: Duration = Duration::from_millis(5); // Keep TCP connection open briefly after WebSocket close +// The WebSocket subprotocol that Rivet clients offer and the gateway speaks. +const RIVET_WS_SUBPROTOCOL: &str = "rivet"; + +// Returns whether the client offered the `rivet` WebSocket subprotocol in its +// `Sec-WebSocket-Protocol` request header. Per RFC 6455 a server may only select +// a subprotocol the client offered, and browsers reject a handshake response that +// echoes an unsolicited subprotocol. Non-Rivet raw clients (for example tldraw's +// `useSync`) connect without offering `rivet`, so the gateway must only echo it +// when the client actually offered it. +fn client_offered_rivet_subprotocol(headers: &hyper::HeaderMap) -> bool { + headers + .get_all(hyper::header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|proto| proto.trim() == RIVET_WS_SUBPROTOCOL) +} + // State shared across all request handlers pub struct ProxyState { config: rivet_config::Config, @@ -543,12 +561,17 @@ impl ProxyService { // Extract the parts from the response but preserve all headers and status let (mut parts, _) = client_response.into_parts(); - // Add Sec-WebSocket-Protocol header to the response - // Many WebSocket clients (e.g. node-ws & Cloudflare) require a protocol in the response - parts.headers.insert( - "sec-websocket-protocol", - hyper::header::HeaderValue::from_static("rivet"), - ); + // Echo the Sec-WebSocket-Protocol header only when the client + // offered the `rivet` subprotocol. Many WebSocket clients (e.g. + // node-ws & Cloudflare) require a protocol in the response, but + // browsers reject a response that echoes a subprotocol the client + // did not offer. + if client_offered_rivet_subprotocol(&req_ctx.headers) { + parts.headers.insert( + "sec-websocket-protocol", + hyper::header::HeaderValue::from_static(RIVET_WS_SUBPROTOCOL), + ); + } // Create a new response with an empty body - WebSocket upgrades don't need a body Response::from_parts( @@ -1860,12 +1883,16 @@ impl ProxyService { // Extract the parts from the response but preserve all headers and status let (mut parts, _) = client_response.into_parts(); - // Add Sec-WebSocket-Protocol header to the response - // Many WebSocket clients (e.g. node-ws & Cloudflare) require a protocol in the response - parts.headers.insert( - "sec-websocket-protocol", - hyper::header::HeaderValue::from_static("rivet"), - ); + // Echo the Sec-WebSocket-Protocol header only when the client offered the + // `rivet` subprotocol. Many WebSocket clients (e.g. node-ws & Cloudflare) + // require a protocol in the response, but browsers reject a response that + // echoes a subprotocol the client did not offer. + if client_offered_rivet_subprotocol(&req_ctx.headers) { + parts.headers.insert( + "sec-websocket-protocol", + hyper::header::HeaderValue::from_static(RIVET_WS_SUBPROTOCOL), + ); + } // Create a new response with an empty body - WebSocket upgrades don't need a body Ok(Response::from_parts( diff --git a/engine/packages/guard/src/routing/pegboard_gateway/mod.rs b/engine/packages/guard/src/routing/pegboard_gateway/mod.rs index e16ec4fdfb..958c9f9cc3 100644 --- a/engine/packages/guard/src/routing/pegboard_gateway/mod.rs +++ b/engine/packages/guard/src/routing/pegboard_gateway/mod.rs @@ -810,25 +810,21 @@ fn read_gateway_token_for_path_based<'a>( } if req_ctx.is_websocket() { - let protocols_header = req_ctx + // The gateway token may be supplied via the `rivet_token.*` entry in the + // sec-websocket-protocol header. It is optional: a plain browser WebSocket + // client (for example tldraw's `useSync`) connects without offering any + // subprotocol, so the absence of the header simply means no token. Auth, if + // required, is enforced downstream; do not reject the upgrade here. + Ok(req_ctx .headers() .get(SEC_WEBSOCKET_PROTOCOL) .and_then(|protocols| protocols.to_str().ok()) - .ok_or_else(|| { - crate::errors::MissingHeader { - header: "sec-websocket-protocol".to_string(), - } - .build() - })?; - - let protocols = protocols_header - .split(',') - .map(|p| p.trim()) - .collect::>(); - - Ok(protocols - .iter() - .find_map(|p| p.strip_prefix(WS_PROTOCOL_TOKEN))) + .and_then(|protocols_header| { + protocols_header + .split(',') + .map(|p| p.trim()) + .find_map(|p| p.strip_prefix(WS_PROTOCOL_TOKEN)) + })) } else { req_ctx .headers()