mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
## Summary Adds support for overriding special WebSocket headers (`Host`, `Sec-WebSocket-Key`, and `Sec-WebSocket-Protocol`) via the headers option when creating a WebSocket connection. ## Changes - Modified `WebSocketUpgradeClient.zig` to check for and use user-provided special headers - Added header value validation to prevent CRLF injection attacks - Updated the NonUTF8Headers struct to automatically filter duplicate headers - When a custom `Sec-WebSocket-Protocol` header is provided, it properly updates the subprotocols list for validation ## Implementation Details The implementation adds minimal code by: 1. Using the existing `NonUTF8Headers` struct's methods to find valid header overrides 2. Automatically filtering out WebSocket-specific headers in the format method to prevent duplication 3. Maintaining a single, clean code path in `buildRequestBody()` ## Testing Added comprehensive tests in `websocket-custom-headers.test.ts` that verify: - Custom Host header support - Custom Sec-WebSocket-Key header support - Custom Sec-WebSocket-Protocol header support - Header override behavior when both protocols array and header are provided - CRLF injection prevention - Protection of system headers (Connection, Upgrade, etc.) - Support for additional custom headers All existing WebSocket tests continue to pass, ensuring backward compatibility. ## Security The implementation includes validation to: - Reject header values with control characters (preventing CRLF injection) - Prevent users from overriding critical system headers like Connection and Upgrade - Validate header values according to RFC 7230 specifications ## Use Cases This feature enables: - Testing WebSocket servers with specific header requirements - Connecting through proxies that require custom Host headers - Implementing custom WebSocket subprotocol negotiation - Debugging WebSocket connections with specific keys Fixes #[issue_number] --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
382 lines
10 KiB
TypeScript
382 lines
10 KiB
TypeScript
import type { Subprocess } from "bun";
|
|
import { spawn } from "bun";
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import { bunEnv, bunExe, nodeExe } from "harness";
|
|
import * as path from "node:path";
|
|
|
|
let servers: Subprocess[] = [];
|
|
let clients: WebSocket[] = [];
|
|
|
|
function cleanUp() {
|
|
for (const client of clients) {
|
|
client.terminate?.();
|
|
}
|
|
for (const server of servers) {
|
|
server.kill();
|
|
}
|
|
clients = [];
|
|
servers = [];
|
|
}
|
|
|
|
beforeEach(cleanUp);
|
|
afterEach(cleanUp);
|
|
|
|
async function createHeaderEchoServer(): Promise<URL> {
|
|
const pathname = path.join(import.meta.dir, "./websocket-server-echo-headers-simple.mjs");
|
|
const { promise, resolve, reject } = Promise.withResolvers<URL>();
|
|
const server = spawn({
|
|
cmd: [nodeExe() ?? bunExe(), pathname],
|
|
cwd: import.meta.dir,
|
|
env: bunEnv,
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
serialization: "json",
|
|
ipc(message) {
|
|
const url = message?.href;
|
|
if (url) {
|
|
try {
|
|
resolve(new URL(url));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
servers.push(server);
|
|
return await promise;
|
|
}
|
|
|
|
describe("WebSocket custom headers", () => {
|
|
it("should send custom Host header", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Host": "custom-host.example.com:8080",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
expect(headers.host).toBe("custom-host.example.com:8080");
|
|
ws.close();
|
|
});
|
|
|
|
it("should reject invalid Sec-WebSocket-Key and generate a valid one", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
// Invalid keys that should be rejected
|
|
const invalidKeys = [
|
|
"not-base64!@#", // Invalid base64
|
|
"dG9vc2hvcnQ=", // Valid base64 but decodes to 8 bytes, not 16
|
|
btoa("toolongkeytoolongkey"), // Valid base64 but decodes to >16 bytes
|
|
];
|
|
|
|
for (const invalidKey of invalidKeys) {
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Sec-WebSocket-Key": invalidKey,
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
const headerPromise = new Promise<any>((res, rej) => {
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
res(data.headers);
|
|
}
|
|
} catch (e) {
|
|
rej(e);
|
|
}
|
|
};
|
|
ws.onerror = rej;
|
|
});
|
|
|
|
const headers = await headerPromise;
|
|
// Should have generated a new valid key instead of using the invalid one
|
|
expect(headers["sec-websocket-key"]).not.toBe(invalidKey);
|
|
// The generated key should be valid base64 that decodes to 16 bytes
|
|
const keyBytes = atob(headers["sec-websocket-key"]);
|
|
expect(keyBytes.length).toBe(16);
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it("should send custom Sec-WebSocket-Key header", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
// Generate a valid base64-encoded 16-byte key
|
|
const keyBytes = new Uint8Array(16);
|
|
crypto.getRandomValues(keyBytes);
|
|
const customKey = btoa(String.fromCharCode(...keyBytes));
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Sec-WebSocket-Key": customKey,
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
expect(headers["sec-websocket-key"]).toBe(customKey);
|
|
ws.close();
|
|
});
|
|
|
|
it("should send custom Sec-WebSocket-Protocol header", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Sec-WebSocket-Protocol": "custom-protocol, another-protocol",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
expect(headers["sec-websocket-protocol"]).toBe("custom-protocol, another-protocol");
|
|
ws.close();
|
|
});
|
|
|
|
it("should override protocol header when both protocols array and header are provided", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
protocols: ["proto1", "proto2"],
|
|
headers: {
|
|
"Sec-WebSocket-Protocol": "custom-protocol",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
// The custom header should override the protocols array
|
|
expect(headers["sec-websocket-protocol"]).toBe("custom-protocol");
|
|
ws.close();
|
|
});
|
|
|
|
it("should send multiple custom headers", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const keyBytes = new Uint8Array(16);
|
|
crypto.getRandomValues(keyBytes);
|
|
const customKey = btoa(String.fromCharCode(...keyBytes));
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Host": "multi-header.example.com",
|
|
"Sec-WebSocket-Key": customKey,
|
|
"Sec-WebSocket-Protocol": "multi-proto",
|
|
"X-Custom-Header": "custom-value",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
expect(headers.host).toBe("multi-header.example.com");
|
|
expect(headers["sec-websocket-key"]).toBe(customKey);
|
|
expect(headers["sec-websocket-protocol"]).toBe("multi-proto");
|
|
expect(headers["x-custom-header"]).toBe("custom-value");
|
|
ws.close();
|
|
});
|
|
|
|
it("should reject CRLF injection in header values", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
|
|
// Test with CRLF injection attempt - this should be rejected
|
|
expect(() => {
|
|
new WebSocket(url.href, {
|
|
headers: {
|
|
"X-Test-Header": "value\r\nInjected-Header: bad",
|
|
},
|
|
});
|
|
}).toThrow("Header 'X-Test-Header' has invalid value");
|
|
});
|
|
|
|
it("should allow headers with special but valid characters", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
// These should be allowed according to HTTP spec
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"X-Special-Chars": "value with spaces and !@#$%^&*()_+-=[]{}|;:',.<>?/`~",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
expect(headers["x-special-chars"]).toContain("value with spaces");
|
|
ws.close();
|
|
});
|
|
|
|
it("should handle empty header values correctly", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"X-Empty-Header": "",
|
|
"X-Whitespace-Header": " ",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
|
|
// Check X-Empty-Header: should either be filtered out or have empty value
|
|
if ("x-empty-header" in headers) {
|
|
expect(headers["x-empty-header"]).toBe("");
|
|
} else {
|
|
// Header was filtered out, which is also acceptable
|
|
expect(headers["x-empty-header"]).toBeUndefined();
|
|
}
|
|
|
|
// Check X-Whitespace-Header: should either be filtered out, trimmed to empty, or have the exact whitespace
|
|
if ("x-whitespace-header" in headers) {
|
|
// Whitespace might be preserved or trimmed - both are acceptable
|
|
expect(["", " "]).toContain(headers["x-whitespace-header"]);
|
|
} else {
|
|
// Header was filtered out, which is also acceptable
|
|
expect(headers["x-whitespace-header"]).toBeUndefined();
|
|
}
|
|
|
|
ws.close();
|
|
});
|
|
|
|
it("should not override system headers like Connection or Upgrade", async () => {
|
|
const url = await createHeaderEchoServer();
|
|
const { promise, resolve, reject } = Promise.withResolvers<any>();
|
|
|
|
const ws = new WebSocket(url.href, {
|
|
headers: {
|
|
"Connection": "close", // Should be ignored
|
|
"Upgrade": "http/2.0", // Should be ignored
|
|
"Sec-WebSocket-Version": "8", // Should be ignored
|
|
"X-Custom": "allowed",
|
|
},
|
|
});
|
|
clients.push(ws);
|
|
|
|
ws.onmessage = event => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "headers") {
|
|
resolve(data.headers);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = reject;
|
|
|
|
const headers = await promise;
|
|
// These should remain as WebSocket requires
|
|
expect(headers.connection.toLowerCase()).toContain("upgrade");
|
|
expect(headers.upgrade.toLowerCase()).toBe("websocket");
|
|
expect(headers["sec-websocket-version"]).toBe("13");
|
|
expect(headers["x-custom"]).toBe("allowed");
|
|
ws.close();
|
|
});
|
|
});
|