Files
bun.sh/test/js/web/websocket/websocket-subprotocol-strict.test.ts
Jarred Sumner 48ebc15e63 Implement RFC 6455 compliant WebSocket subprotocol handling (#22323)
## Summary

- Implements proper WebSocket subprotocol negotiation per RFC 6455 and
WHATWG standards
- Adds HeaderValueIterator utility for parsing comma-separated header
values
- Fixes WebSocket client to correctly validate server subprotocol
responses
- Sets WebSocket.protocol property to negotiated subprotocol per WHATWG
spec
- Includes comprehensive test coverage for all subprotocol scenarios

## Changes

**Core Implementation:**
- Add `HeaderValueIterator` utility for parsing comma-separated HTTP
header values
- Replace hash-based protocol matching with proper string set comparison
- Implement WHATWG compliant protocol property setting on successful
negotiation

**WebSocket Client (`WebSocketUpgradeClient.zig`):**
- Parse client subprotocols into StringSet using HeaderValueIterator
- Validate server response against requested protocols
- Set protocol property when server selects a matching subprotocol
- Allow connections when server omits Sec-WebSocket-Protocol header (per
spec)
- Reject connections when server sends unknown or empty subprotocol
values

**C++ Bindings:**
- Add `setProtocol` method to WebSocket class for updating protocol
property
- Export C binding for Zig integration

## Test Plan

Comprehensive test coverage for all subprotocol scenarios:
-  Server omits Sec-WebSocket-Protocol header (connection allowed,
protocol="")
-  Server sends empty Sec-WebSocket-Protocol header (connection
rejected)
-  Server selects valid subprotocol from multiple client options
(protocol set correctly)
-  Server responds with unknown subprotocol (connection rejected with
code 1002)
-  Validates CloseEvent objects don't trigger [Circular] console bugs

All tests use proper WebSocket handshake implementation and validate
both client and server behavior per RFC 6455 requirements.

## Issues Fixed

Fixes #10459 - WebSocket client does not retrieve the protocol sent by
the server
Fixes #10672 - `obs-websocket-js` is not compatible with Bun  
Fixes #17707 - Incompatibility with NodeJS when using obs-websocket-js
library
Fixes #19785 - Mismatch client protocol when connecting with multiple
Sec-WebSocket-Protocol

This enables obs-websocket-js and other libraries that rely on proper
RFC 6455 subprotocol negotiation to work correctly with Bun.

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-02 03:47:25 -07:00

192 lines
7.2 KiB
TypeScript

import { describe, expect, it, mock } from "bun:test";
import crypto from "node:crypto";
import net from "node:net";
describe("WebSocket strict RFC 6455 subprotocol handling", () => {
async function createTestServer(
responseHeaders: string[],
): Promise<{ port: number; [Symbol.asyncDispose]: () => Promise<void> }> {
const server = net.createServer();
let port: number;
await new Promise<void>(resolve => {
server.listen(0, () => {
port = (server.address() as any).port;
resolve();
});
});
server.on("connection", socket => {
let requestData = "";
socket.on("data", data => {
requestData += data.toString();
if (requestData.includes("\r\n\r\n")) {
const lines = requestData.split("\r\n");
let websocketKey = "";
for (const line of lines) {
if (line.startsWith("Sec-WebSocket-Key:")) {
websocketKey = line.split(":")[1].trim();
break;
}
}
const acceptKey = crypto
.createHash("sha1")
.update(websocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
.digest("base64");
const response = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${acceptKey}`,
...responseHeaders,
"\r\n",
].join("\r\n");
socket.write(response);
}
});
});
return {
port: port!,
[Symbol.asyncDispose]: async () => {
server.close();
},
};
}
async function expectConnectionFailure(port: number, protocols: string[], expectedCode = 1002) {
const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers();
const ws = new WebSocket(`ws://localhost:${port}`, protocols);
const onopenMock = mock(() => {});
ws.onopen = onopenMock;
ws.onclose = close => {
expect(close.code).toBe(expectedCode);
expect(close.reason).toBe("Mismatch client protocol");
resolveClose();
};
await closePromise;
expect(onopenMock).not.toHaveBeenCalled();
}
async function expectConnectionSuccess(port: number, protocols: string[], expectedProtocol: string) {
const { promise: openPromise, resolve: resolveOpen, reject } = Promise.withResolvers();
const ws = new WebSocket(`ws://localhost:${port}`, protocols);
try {
ws.onopen = () => resolveOpen();
ws.onerror = reject;
await openPromise;
expect(ws.protocol).toBe(expectedProtocol);
} finally {
ws.terminate();
}
}
// Multiple protocols in single header (comma-separated) - should fail
it("should reject multiple comma-separated protocols", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat, echo"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject multiple comma-separated protocols with spaces", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat , echo , binary"]);
await expectConnectionFailure(server.port, ["chat", "echo", "binary"]);
});
it("should reject multiple comma-separated protocols (3 protocols)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: a,b,c"]);
await expectConnectionFailure(server.port, ["a", "b", "c"]);
});
// Multiple headers - should fail
it("should reject duplicate Sec-WebSocket-Protocol headers (same value)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat", "Sec-WebSocket-Protocol: chat"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject duplicate Sec-WebSocket-Protocol headers (different values)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat", "Sec-WebSocket-Protocol: echo"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject three Sec-WebSocket-Protocol headers", async () => {
await using server = await createTestServer([
"Sec-WebSocket-Protocol: a",
"Sec-WebSocket-Protocol: b",
"Sec-WebSocket-Protocol: c",
]);
await expectConnectionFailure(server.port, ["a", "b", "c"]);
});
// Empty values - should fail
it("should reject empty Sec-WebSocket-Protocol header", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: "]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject Sec-WebSocket-Protocol with only comma", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: ,"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject Sec-WebSocket-Protocol with only spaces", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: "]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
// Unknown protocols - should fail
it("should reject unknown single protocol", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: unknown"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
it("should reject unknown protocol (not in client list)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: binary"]);
await expectConnectionFailure(server.port, ["chat", "echo"]);
});
// Valid cases - should succeed
it("should accept single valid protocol (first in client list)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat"]);
await expectConnectionSuccess(server.port, ["chat", "echo", "binary"], "chat");
});
it("should accept single valid protocol (middle in client list)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: echo"]);
await expectConnectionSuccess(server.port, ["chat", "echo", "binary"], "echo");
});
it("should accept single valid protocol (last in client list)", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: binary"]);
await expectConnectionSuccess(server.port, ["chat", "echo", "binary"], "binary");
});
it("should accept single protocol with extra whitespace", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: echo "]);
await expectConnectionSuccess(server.port, ["chat", "echo"], "echo");
});
it("should accept single protocol with single character", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: a"]);
await expectConnectionSuccess(server.port, ["a", "b"], "a");
});
// Edge cases with special characters
it("should handle protocol with special characters", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: chat-v2.0"]);
await expectConnectionSuccess(server.port, ["chat-v1.0", "chat-v2.0"], "chat-v2.0");
});
it("should handle protocol with dots", async () => {
await using server = await createTestServer(["Sec-WebSocket-Protocol: com.example.chat"]);
await expectConnectionSuccess(server.port, ["com.example.chat", "other"], "com.example.chat");
});
});