Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions engine/packages/guard-core/src/proxy_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 12 additions & 16 deletions engine/packages/guard/src/routing/pegboard_gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<&str>>();

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()
Expand Down
Loading