From 37fc8e99f7eefffac72188fe7fd4a533cc2cba19 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 28 Dec 2025 17:58:24 -0800 Subject: [PATCH] Harden WebSocket client decompression (#25724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude --- src/http/websocket_client.zig | 1 + .../websocket_client/WebSocketDeflate.zig | 14 +- ...cket-permessage-deflate-edge-cases.test.ts | 135 +++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/src/http/websocket_client.zig b/src/http/websocket_client.zig index 5845156238..45e19a3a38 100644 --- a/src/http/websocket_client.zig +++ b/src/http/websocket_client.zig @@ -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); diff --git a/src/http/websocket_client/WebSocketDeflate.zig b/src/http/websocket_client/WebSocketDeflate.zig index d6f5acef89..935721589a 100644 --- a/src/http/websocket_client/WebSocketDeflate.zig +++ b/src/http/websocket_client/WebSocketDeflate.zig @@ -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; } diff --git a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts index 934f2f0e3b..37a718e034 100644 --- a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts +++ b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts @@ -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(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(resolve => tcpServer.close(() => resolve())); + } +});