mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Harden WebSocket client decompression (#25724)
## Summary - Add maximum decompressed message size limit to WebSocket client deflate handling - Add test coverage for decompression limits ## Test plan - Run `bun test test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
deflate.decompress(data_, &decompressed) catch |err| {
|
||||
const error_code = switch (err) {
|
||||
error.InflateFailed => ErrorCode.invalid_compressed_data,
|
||||
error.TooLarge => ErrorCode.message_too_big,
|
||||
error.OutOfMemory => ErrorCode.failed_to_allocate_memory,
|
||||
};
|
||||
this.terminate(error_code);
|
||||
|
||||
@@ -76,6 +76,9 @@ const Z_DEFAULT_MEM_LEVEL = 8;
|
||||
// Buffer size for compression/decompression operations
|
||||
const COMPRESSION_BUFFER_SIZE = 4096;
|
||||
|
||||
// Maximum decompressed message size (128 MB)
|
||||
const MAX_DECOMPRESSED_SIZE: usize = 128 * 1024 * 1024;
|
||||
|
||||
// DEFLATE trailer bytes added by Z_SYNC_FLUSH
|
||||
const DEFLATE_TRAILER = [_]u8{ 0x00, 0x00, 0xff, 0xff };
|
||||
|
||||
@@ -136,13 +139,17 @@ fn canUseLibDeflate(len: usize) bool {
|
||||
return len < RareData.stack_buffer_size;
|
||||
}
|
||||
|
||||
pub fn decompress(self: *PerMessageDeflate, in_buf: []const u8, out: *std.array_list.Managed(u8)) error{ InflateFailed, OutOfMemory }!void {
|
||||
pub fn decompress(self: *PerMessageDeflate, in_buf: []const u8, out: *std.array_list.Managed(u8)) error{ InflateFailed, OutOfMemory, TooLarge }!void {
|
||||
const initial_len = out.items.len;
|
||||
|
||||
// First we try with libdeflate, which is both faster and doesn't need the trailing deflate bytes
|
||||
if (canUseLibDeflate(in_buf.len)) {
|
||||
const result = self.rare_data.decompressor().deflate(in_buf, out.unusedCapacitySlice());
|
||||
if (result.status == .success) {
|
||||
out.items.len += result.written;
|
||||
if (out.items.len - initial_len > MAX_DECOMPRESSED_SIZE) {
|
||||
return error.TooLarge;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +170,11 @@ pub fn decompress(self: *PerMessageDeflate, in_buf: []const u8, out: *std.array_
|
||||
const res = zlib.inflate(&self.decompress_stream, zlib.FlushValue.NoFlush);
|
||||
out.items.len += out.unusedCapacitySlice().len - self.decompress_stream.avail_out;
|
||||
|
||||
// Check for decompression bomb
|
||||
if (out.items.len - initial_len > MAX_DECOMPRESSED_SIZE) {
|
||||
return error.TooLarge;
|
||||
}
|
||||
|
||||
if (res == .StreamEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { serve } from "bun";
|
||||
import { expect, test } from "bun:test";
|
||||
import { expect, setDefaultTimeout, test } from "bun:test";
|
||||
|
||||
// The decompression bomb test needs extra time to compress 150MB of test data
|
||||
setDefaultTimeout(30_000);
|
||||
|
||||
// Test compressed continuation frames
|
||||
test("WebSocket client handles compressed continuation frames correctly", async () => {
|
||||
@@ -198,3 +201,133 @@ test("WebSocket client handles compression errors gracefully", async () => {
|
||||
client.close();
|
||||
server.stop();
|
||||
});
|
||||
|
||||
// Test that decompression is limited to prevent decompression bombs
|
||||
test("WebSocket client rejects decompression bombs", async () => {
|
||||
const net = await import("net");
|
||||
const zlib = await import("zlib");
|
||||
const crypto = await import("crypto");
|
||||
|
||||
// Create a raw TCP server that speaks WebSocket protocol
|
||||
const tcpServer = net.createServer();
|
||||
|
||||
const serverReady = new Promise<number>(resolve => {
|
||||
tcpServer.listen(0, () => {
|
||||
const addr = tcpServer.address();
|
||||
resolve(typeof addr === "object" && addr ? addr.port : 0);
|
||||
});
|
||||
});
|
||||
|
||||
const port = await serverReady;
|
||||
|
||||
tcpServer.on("connection", socket => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
socket.on("data", data => {
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
|
||||
// Look for end of HTTP headers
|
||||
const headerEnd = buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd === -1) return;
|
||||
|
||||
const headers = buffer.slice(0, headerEnd).toString();
|
||||
|
||||
// Extract Sec-WebSocket-Key
|
||||
const keyMatch = headers.match(/Sec-WebSocket-Key: ([A-Za-z0-9+/=]+)/i);
|
||||
if (!keyMatch) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = keyMatch[1];
|
||||
const acceptKey = crypto
|
||||
.createHash("sha1")
|
||||
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
|
||||
.digest("base64");
|
||||
|
||||
// Send WebSocket upgrade response with permessage-deflate
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
"Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n" +
|
||||
"\r\n",
|
||||
);
|
||||
|
||||
// Create a decompression bomb: 150MB of zeros (exceeds the 128MB limit)
|
||||
const uncompressedSize = 150 * 1024 * 1024;
|
||||
const payload = Buffer.alloc(uncompressedSize, 0);
|
||||
|
||||
// Compress with raw deflate (no header, no trailing bytes that permessage-deflate removes)
|
||||
const compressed = zlib.deflateRawSync(payload, { level: 9 });
|
||||
|
||||
// Build WebSocket frame (binary, FIN=1, RSV1=1 for compression)
|
||||
// Frame format: FIN(1) RSV1(1) RSV2(0) RSV3(0) Opcode(4) Mask(1) PayloadLen(7) [ExtendedLen] [MaskKey] Payload
|
||||
const frameHeader: number[] = [];
|
||||
|
||||
// First byte: FIN=1, RSV1=1 (compressed), RSV2=0, RSV3=0, Opcode=2 (binary)
|
||||
frameHeader.push(0b11000010);
|
||||
|
||||
// Second byte: Mask=0 (server to client), payload length
|
||||
if (compressed.length < 126) {
|
||||
frameHeader.push(compressed.length);
|
||||
} else if (compressed.length < 65536) {
|
||||
frameHeader.push(126);
|
||||
frameHeader.push((compressed.length >> 8) & 0xff);
|
||||
frameHeader.push(compressed.length & 0xff);
|
||||
} else {
|
||||
frameHeader.push(127);
|
||||
// 64-bit length (we only need lower 32 bits for this test)
|
||||
frameHeader.push(0, 0, 0, 0);
|
||||
frameHeader.push((compressed.length >> 24) & 0xff);
|
||||
frameHeader.push((compressed.length >> 16) & 0xff);
|
||||
frameHeader.push((compressed.length >> 8) & 0xff);
|
||||
frameHeader.push(compressed.length & 0xff);
|
||||
}
|
||||
|
||||
const frame = Buffer.concat([Buffer.from(frameHeader), compressed]);
|
||||
socket.write(frame);
|
||||
});
|
||||
});
|
||||
|
||||
let client: WebSocket | null = null;
|
||||
let messageReceived = false;
|
||||
|
||||
try {
|
||||
// Connect with Bun's WebSocket client
|
||||
client = new WebSocket(`ws://localhost:${port}`);
|
||||
|
||||
const result = await new Promise<{ code: number; reason: string }>(resolve => {
|
||||
client!.onopen = () => {
|
||||
// Connection opened, waiting for the bomb to be sent
|
||||
};
|
||||
|
||||
client!.onmessage = () => {
|
||||
// Should NOT receive the message - it should be rejected
|
||||
messageReceived = true;
|
||||
};
|
||||
|
||||
client!.onerror = () => {
|
||||
// Error is expected
|
||||
};
|
||||
|
||||
client!.onclose = event => {
|
||||
resolve({
|
||||
code: messageReceived ? -1 : event.code,
|
||||
reason: messageReceived ? "Message was received but should have been rejected" : event.reason,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// The connection should be closed with code 1009 (Message Too Big)
|
||||
expect(result.code).toBe(1009);
|
||||
expect(result.reason).toBe("Message too big");
|
||||
} finally {
|
||||
// Ensure cleanup happens even on test failure/timeout
|
||||
if (client && client.readyState !== WebSocket.CLOSED) {
|
||||
client.close();
|
||||
}
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user