Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions src/http/Decompressor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,6 @@ unsafe fn seat<'a>(input: &'a [u8], out: &'a mut Vec<u8>) -> (&'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
Expand All @@ -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 {
Expand All @@ -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(());
}
Expand Down
65 changes: 64 additions & 1 deletion test/js/web/fetch/fetch-gzip.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading