From e44ba0887434f93b0ee40d1bf6ed7819ad689339 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:34:27 +0000 Subject: [PATCH 1/2] http: remove 1 GiB decompressed-body cap from fetch Decompressor The Rust port set max_output_size = 1 GiB on the zlib/brotli/zstd readers in Decompressor::update_buffers. Decompressor.zig never set this field, so the readers ran unbounded (limited only by available memory). In buffered mode the output accumulates across the whole response, so any compressed body whose decompressed size exceeds 1 GiB failed in read_all with ZlibError/BrotliError/ZstdError where previously it completed. Drop the assignments so the readers keep their usize::MAX default, restoring parity with the Zig implementation. --- src/http/Decompressor.rs | 14 ++---- test/js/web/fetch/fetch-gzip.test.ts | 73 +++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/http/Decompressor.rs b/src/http/Decompressor.rs index 6959313529ed..75ec63030acb 100644 --- a/src/http/Decompressor.rs +++ b/src/http/Decompressor.rs @@ -52,11 +52,6 @@ unsafe fn seat<'a>(input: &'a [u8], out: &'a mut Vec) -> (&'static [u8], &'s } } -/// Decompression-bomb guard for response bodies inflated on the HTTP thread: -/// a hostile server must not be able to expand a tiny compressed payload into -/// an unbounded allocation. -const MAX_DECOMPRESSED_BODY_SIZE: usize = 1024 * 1024 * 1024; - impl Decompressor { // Note: the boxed readers' `Drop` impls call `end()`, so an // explicit `Drop` is unnecessary. Callers that want a mid-lifecycle reset @@ -78,7 +73,7 @@ impl Decompressor { let (input, out) = unsafe { seat(buffer, &mut body_out_str.list) }; match encoding { Encoding::Gzip | Encoding::Deflate => { - let mut reader = ZlibReaderArrayList::init_with_options_and_list_allocator( + let reader = ZlibReaderArrayList::init_with_options_and_list_allocator( input, out, bun_zlib::Options { @@ -96,20 +91,17 @@ impl Decompressor { ..Default::default() }, )?; - reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; *self = Decompressor::Zlib(reader); return Ok(()); } Encoding::Brotli => { - let mut reader = + let reader = BrotliReaderArrayList::new_with_options(input, out, &Default::default())?; - reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; *self = Decompressor::Brotli(reader); return Ok(()); } Encoding::Zstd => { - let mut reader = ZstdReaderArrayList::init_with_list_allocator(input, out)?; - reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; + let reader = ZstdReaderArrayList::init_with_list_allocator(input, out)?; *self = Decompressor::Zstd(reader); return Ok(()); } diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 892394375e2e..4f4352b1a9e7 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -1,6 +1,6 @@ import { Socket } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; -import { gcTick } from "harness"; +import { bunEnv, bunExe, gcTick } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; import { createServer as createNetServer } from "node:net"; @@ -297,6 +297,77 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don done(); }); +// A buffered (non-streaming) fetch() must be able to decompress a response +// body larger than 1 GiB. The HTTP client's Decompressor runs unbounded, the +// same as the original Zig implementation; only available memory limits it. +// Run in a subprocess so the ~1 GiB output buffer does not linger in the test +// process. +it( + "fetch() with a buffered gzip response whose decompressed size exceeds 1 GiB works", + async () => { + const fixture = /* js */ ` + import { createGzip } from "node:zlib"; + + const CHUNK = Buffer.alloc(1024 * 1024); + const N = 1025; // 1 GiB + 1 MiB + const chunks = []; + await new Promise((resolve, reject) => { + const gz = createGzip(); + gz.on("data", c => chunks.push(c)); + gz.on("end", resolve); + gz.on("error", reject); + let i = 0; + const pump = () => { + while (i < N) { + i++; + if (!gz.write(CHUNK)) return void gz.once("drain", pump); + } + gz.end(); + }; + pump(); + }); + const body = Buffer.concat(chunks); + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Response(body, { + headers: { + "Content-Encoding": "gzip", + "Content-Length": String(body.length), + }, + }); + }, + }); + try { + const res = await fetch(\`http://127.0.0.1:\${server.port}/\`); + const buf = await res.arrayBuffer(); + console.log("OK", buf.byteLength); + } finally { + server.stop(true); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: `OK ${1025 * 1024 * 1024}`, + stderr: expect.not.stringContaining("error"), + exitCode: 0, + }); + }, + 60_000, +); + describe("empty compressed responses", () => { // A response that declares Content-Encoding but sends zero body bytes must // resolve as an empty body, like Node — not fail with ZlibError. From 61992de34ae7e86ba7d8f558f3e911024da7d12c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:36:47 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch-gzip.test.ts | 38 +++++++++++----------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 4f4352b1a9e7..97a397cf645e 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -302,10 +302,8 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don // same as the original Zig implementation; only available memory limits it. // Run in a subprocess so the ~1 GiB output buffer does not linger in the test // process. -it( - "fetch() with a buffered gzip response whose decompressed size exceeds 1 GiB works", - async () => { - const fixture = /* js */ ` +it("fetch() with a buffered gzip response whose decompressed size exceeds 1 GiB works", async () => { + const fixture = /* js */ ` import { createGzip } from "node:zlib"; const CHUNK = Buffer.alloc(1024 * 1024); @@ -348,25 +346,19 @@ it( server.stop(true); } `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", fixture], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ - stdout: `OK ${1025 * 1024 * 1024}`, - stderr: expect.not.stringContaining("error"), - exitCode: 0, - }); - }, - 60_000, -); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: `OK ${1025 * 1024 * 1024}`, + stderr: expect.not.stringContaining("error"), + exitCode: 0, + }); +}, 60_000); describe("empty compressed responses", () => { // A response that declares Content-Encoding but sends zero body bytes must