From 6702342948c2ca25872b95286f9f579afd38a472 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:34:37 +0000 Subject: [PATCH 1/3] Bun.serve(http3): send a response whose header block is larger than 64 KB lsquic's send_headers_ietf encoded the header block into a fixed 64 KB stack buffer and failed with QWH_ENOBUF for a larger block, so the response never went out. A new lsquic patch sizes the buffer from the header list and uses the heap above 64 KB. The lsxpack offsets are 16-bit. The send and decode paths in quic.c and the send path in node_quic_shim.c addressed every field from the start of one shared buffer, so more than 64 KB of fields tripped the offset assertion in debug builds. Each field now points at its own slice of the buffer. The send-side hunks are the same as in #42895. --- packages/bun-usockets/src/node_quic_shim.c | 8 +- packages/bun-usockets/src/quic.c | 30 +++++--- patches/lsquic/large-header-block.patch | 86 ++++++++++++++++++++++ scripts/build/deps/lsquic.ts | 5 ++ test/js/bun/http/serve-http3.test.ts | 24 ++++++ 5 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 patches/lsquic/large-header-block.patch diff --git a/packages/bun-usockets/src/node_quic_shim.c b/packages/bun-usockets/src/node_quic_shim.c index 95b40de6d9ce..486ba7c0aeac 100644 --- a/packages/bun-usockets/src/node_quic_shim.c +++ b/packages/bun-usockets/src/node_quic_shim.c @@ -318,10 +318,12 @@ int us_nq_stream_send_headers(lsquic_stream_t *s, const char *buf, size_t len, if (i >= len) break; i++; unsigned char flags = (i < len) ? (unsigned char) buf[i++] : 0; - if (name_len > LSXPACK_MAX_STRLEN || val_len > LSXPACK_MAX_STRLEN) + if (name_len >= LSXPACK_MAX_STRLEN || val_len > LSXPACK_MAX_STRLEN) return -1; - lsxpack_header_set_offset2(&hdrs[count], buf, name_off, name_len, - val_off, val_len); + /* Per-entry base keeps val_offset (= name_len+1) under the setter's + * LSXPACK_MAX_STRLEN assert no matter how large the joined buffer is. */ + lsxpack_header_set_offset2(&hdrs[count], buf + name_off, 0, name_len, + val_off - name_off, val_len); if (flags & 1) { hdrs[count].flags = LSXPACK_NEVER_INDEX; hdrs[count].indexed_type = 2; diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 93312a46b7db..d7a4a6478643 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -529,14 +529,16 @@ static struct lsxpack_header *us_quic_hsi_prepare(void *hset_p, struct lsxpack_h h->buf = nb; h->cap = ncap; } + /* lsxpack offsets are 16-bit, so the field's buf starts at its own slice + * (h->buf + h->len) and its offsets count from there, not from h->buf. */ if (hdr == NULL) { hdr = &h->scratch; - lsxpack_header_prepare_decode(hdr, h->buf, h->len, space); + lsxpack_header_prepare_decode(hdr, h->buf + h->len, 0, space); } else { /* Resize: lsqpack already wrote part of name/value into the previous * buffer; only the storage may move. Preserve offsets, repoint buf, * and report the larger window via val_len. */ - hdr->buf = h->buf; + hdr->buf = h->buf + h->len; hdr->val_len = (lsxpack_strlen_t) space; } return hdr; @@ -557,13 +559,13 @@ static int us_quic_hsi_process(void *hset_p, struct lsxpack_header *hdr) { /* lsxpack wrote name+value into h->buf at h->len; record offsets, then * advance len so the next header lands after this one. We store offsets * (cast to pointer-sized) and resolve them after the buffer stops moving. */ - h->headers[h->count].name = (const char *)(uintptr_t) hdr->name_offset; + h->headers[h->count].name = (const char *)(uintptr_t) (h->len + hdr->name_offset); h->headers[h->count].name_len = hdr->name_len; - h->headers[h->count].value = (const char *)(uintptr_t) hdr->val_offset; + h->headers[h->count].value = (const char *)(uintptr_t) (h->len + hdr->val_offset); h->headers[h->count].value_len = hdr->val_len; h->headers[h->count].qpack_index = -1; h->count++; - h->len = (unsigned int) hdr->val_offset + hdr->val_len + hdr->dec_overhead; + h->len += (unsigned int) hdr->val_offset + hdr->val_len + hdr->dec_overhead; return 0; } @@ -1169,8 +1171,13 @@ int us_quic_stream_send_headers(us_quic_stream_t *s, * so each pair has to be contiguous. The caller hands us arbitrary * pointers, so flatten here. */ size_t total = 0; - for (unsigned int i = 0; i < count; i++) + for (unsigned int i = 0; i < count; i++) { + /* lsxpack_header stores each length in 16 bits; a longer one would + * go out truncated. */ + if (headers[i].name_len > LSXPACK_MAX_STRLEN || headers[i].value_len > LSXPACK_MAX_STRLEN) + return -1; total += headers[i].name_len + headers[i].value_len; + } char stackbuf[1024]; char *buf = total <= sizeof(stackbuf) ? stackbuf : (char *) us_malloc(total); @@ -1186,10 +1193,13 @@ int us_quic_stream_send_headers(us_quic_stream_t *s, size_t off = 0; for (unsigned int i = 0; i < count; i++) { const struct us_quic_header_t *h = &headers[i]; - memcpy(buf + off, h->name, h->name_len); - memcpy(buf + off + h->name_len, h->value, h->value_len); - lsxpack_header_set_offset2(&xh[i], buf, off, h->name_len, - off + h->name_len, h->value_len); + char *pair = buf + off; + memcpy(pair, h->name, h->name_len); + memcpy(pair + h->name_len, h->value, h->value_len); + /* Offsets are relative to this pair, not to buf: lsxpack asserts + * that an offset fits 16 bits, and `total` can be larger. */ + lsxpack_header_set_offset2(&xh[i], pair, 0, h->name_len, + h->name_len, h->value_len); if (h->qpack_index >= 0) { xh[i].qpack_index = (uint8_t) h->qpack_index; xh[i].flags = LSXPACK_QPACK_IDX; diff --git a/patches/lsquic/large-header-block.patch b/patches/lsquic/large-header-block.patch new file mode 100644 index 000000000000..959df938eedd --- /dev/null +++ b/patches/lsquic/large-header-block.patch @@ -0,0 +1,86 @@ +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -53,6 +53,7 @@ + #include "lsquic_ev_log.h" + #include "lsquic_enc_sess.h" + #include "lsqpack.h" ++#include "lsxpack_header.h" + #include "lsquic_frab_list.h" + #include "lsquic_http1x_if.h" + #include "lsquic_qdec_hdl.h" +@@ -4113,9 +4114,28 @@ stream_write_buf (struct lsquic_stream *stream, const void *buf, size_t sz) + } + + +-/* This limits the cumulative size of the compressed header fields */ ++/* Compressed header blocks up to this size are encoded on the stack. Larger ++ * ones go through a heap buffer sized from the header list. ++ */ + #define MAX_HEADERS_SIZE (64 * 1024) + ++/* bun: upper bound on the size of the compressed header block. QPACK picks ++ * Huffman only when it is shorter than the literal, so a field line is at ++ * most its name and value plus one prefix byte and one integer for each. ++ */ ++static size_t ++headers_max_encoded_size (const struct lsquic_http_headers *headers) ++{ ++ size_t size = 0; ++ int i; ++ ++ for (i = 0; i < headers->count; ++i) ++ if (headers->headers[i].buf) ++ size += 2 * LSQPACK_UINT64_ENC_SZ + headers->headers[i].name_len ++ + headers->headers[i].val_len; ++ return size; ++} ++ + static int + send_headers_ietf (struct lsquic_stream *stream, + const struct lsquic_http_headers *headers, int eos) +@@ -4132,7 +4152,11 @@ send_headers_ietf (struct lsquic_stream *stream, + unsigned char *header_block; + enum lsqpack_enc_header_flags hflags; + int rv; +- const size_t buf_sz = max_push_size + max_prefix_size + MAX_HEADERS_SIZE; ++ const size_t max_headers_size = headers_max_encoded_size(headers); ++ const size_t buf_sz = max_push_size + max_prefix_size ++ + (max_headers_size > MAX_HEADERS_SIZE ? max_headers_size ++ : MAX_HEADERS_SIZE); ++ unsigned char *buf; + + if (stream->sm_send_headers_state != SSHS_BEGIN || stream->sm_header_block) + { +@@ -4141,9 +4165,20 @@ send_headers_ietf (struct lsquic_stream *stream, + return -1; + } + #ifndef WIN32 +- unsigned char buf[buf_sz]; ++ unsigned char stack_buf[max_push_size + max_prefix_size + MAX_HEADERS_SIZE]; ++ if (max_headers_size > MAX_HEADERS_SIZE) ++ { ++ buf = malloc(buf_sz); ++ if (!buf) ++ { ++ LSQ_WARN("cannot allocate %zu bytes for header block", buf_sz); ++ return -1; ++ } ++ } ++ else ++ buf = stack_buf; + #else +- unsigned char *buf = _malloca(buf_sz); ++ buf = _malloca(buf_sz); + if (!buf) + return -1; + #endif +@@ -4234,6 +4269,9 @@ send_headers_ietf (struct lsquic_stream *stream, + clean: + #ifdef WIN32 + _freea(buf); ++#else ++ if (buf != stack_buf) ++ free(buf); + #endif + return rv; + diff --git a/scripts/build/deps/lsquic.ts b/scripts/build/deps/lsquic.ts index b60d95f0828d..fed472f33721 100644 --- a/scripts/build/deps/lsquic.ts +++ b/scripts/build/deps/lsquic.ts @@ -134,6 +134,11 @@ export const lsquic: Dependency = { // H3_CLOSED_CRITICAL_STREAM and closes the connection, which kills the // requests the graceful stop was draining. Reject only request streams. "patches/lsquic/goaway-accept-uni-streams.patch", + // send_headers_ietf() encoded the header block into a fixed 64 KB stack + // buffer and failed with QWH_ENOBUF for a larger block, so a response + // with more than 64 KB of headers never went out. Size the buffer from + // the header list and use the heap above 64 KB. + "patches/lsquic/large-header-block.patch", ], fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"], diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index da10c4a09a38..4ccb752e87cf 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -129,6 +129,15 @@ const server = serve({ if (url.pathname === "/big") { return new Response(big, { headers: { "content-type": "application/octet-stream" } }); } + if (url.pathname === "/big-headers") { + const n = Number(url.searchParams.get("n")); + const size = Number(url.searchParams.get("size")); + // "~" is 13 bits in the QPACK Huffman table, so the values stay literals. + const value = Buffer.alloc(size, "~").toString(); + const headers = {}; + for (let i = 0; i < n; i++) headers["x-big-" + i] = value; + return new Response("ok", { headers }); + } if (url.pathname === "/status") { return new Response(null, { status: 204 }); } @@ -547,6 +556,21 @@ describe("Bun.serve HTTP/3 adversarial", () => { }); }); + test("response with more than 64 KB of headers is sent", async () => { + // 100 x 700 bytes is about 70 KB after QPACK encoding. lsquic encoded the + // header block into a 64 KB buffer and dropped anything larger, so the + // response never went out and the client waited until its idle timeout. + await withServer(async port => { + const n = 100; + const size = 700; + const res = await fetchH3(port, `/big-headers?n=${n}&size=${size}`); + expect(res.status).toBe(200); + const lengths = Array.from({ length: n }, (_, i) => res.headers.get(`x-big-${i}`)?.length); + expect(lengths).toEqual(Array(n).fill(size)); + expect(await res.text()).toBe("ok"); + }); + }); + test("8 MB POST body echoes byte-exact", async () => { await withServer(async port => { // Patterned (not crypto-random) so the test is deterministic but still From 92e86dec3ffa1161fd685de7f852ebc812781a7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:40:13 +0000 Subject: [PATCH 2/3] test: compare the large header values, cover the node:quic send path --- test/js/bun/http/serve-http3.test.ts | 5 ++- test/js/node/quic/quic-stream.test.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 4ccb752e87cf..683f77365b28 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -565,8 +565,9 @@ describe("Bun.serve HTTP/3 adversarial", () => { const size = 700; const res = await fetchH3(port, `/big-headers?n=${n}&size=${size}`); expect(res.status).toBe(200); - const lengths = Array.from({ length: n }, (_, i) => res.headers.get(`x-big-${i}`)?.length); - expect(lengths).toEqual(Array(n).fill(size)); + const value = Buffer.alloc(size, "~").toString(); + const values = Array.from({ length: n }, (_, i) => res.headers.get(`x-big-${i}`)); + expect(values).toEqual(Array(n).fill(value)); expect(await res.text()).toBe("ok"); }); }); diff --git a/test/js/node/quic/quic-stream.test.ts b/test/js/node/quic/quic-stream.test.ts index 963c1e61d728..5547e972fb86 100644 --- a/test/js/node/quic/quic-stream.test.ts +++ b/test/js/node/quic/quic-stream.test.ts @@ -178,6 +178,59 @@ describe("HTTP/3 header encoding", () => { expect(seen.length).toBe(1); expect(Object.keys(seen[0])).not.toContain("authorization"); }); + + // The joined name/value buffer is about 70 KB. lsxpack offsets are 16-bit, + // so each header has to address its own slice of that buffer, and lsquic + // has to encode a header block larger than its 64 KB stack buffer. + test("sends a response whose header block is larger than 64 KB", async () => { + const n = 100; + const value = Buffer.alloc(700, "~").toString(); + const big: Record = { ":status": "200" }; + for (let i = 0; i < n; i++) big["x-big-" + i] = value; + const application = { maxHeaderLength: 1 << 20, maxHeaderPairs: 1000 }; + + await using server = await listen( + async serverSession => { + serverSession.onstream = (stream: any) => { + stream.closed.catch(() => {}); + }; + await serverSession.closed.catch(() => {}); + }, + { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + application, + onheaders(this: any) { + this.sendHeaders(big, { terminal: true }); + }, + }, + ); + + // The header limits are baked into the client engine when its endpoint is + // created, so a reused endpoint from an earlier test keeps the defaults. + const client = await connect(server.address, { + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 1 }, + application, + reuseEndpoint: false, + }); + await client.opened; + + const got = Promise.withResolvers>(); + await client.createBidirectionalStream({ + headers: { ":method": "GET", ":path": "/", ":scheme": "https", ":authority": "localhost" }, + onheaders(headers: Record) { + got.resolve(headers); + }, + }); + + const headers = await got.promise; + expect(headers[":status"]).toBe("200"); + const values = Array.from({ length: n }, (_, i) => headers["x-big-" + i]); + expect(values).toEqual(Array(n).fill(value)); + client.close(); + }); }); // lsquic decodes a header block that follows another one while the stream is From 438ac1ab946ce66af49a02401c3aa5fa934afebc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:47:38 +0000 Subject: [PATCH 3/3] ci: retrigger