mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
## 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>
192 lines
7.2 KiB
TypeScript
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");
|
|
});
|
|
});
|