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..97a397cf645e 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,69 @@ 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.