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..683f77365b28 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,22 @@ 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 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"); + }); + }); + test("8 MB POST body echoes byte-exact", async () => { await withServer(async port => { // Patterned (not crypto-random) so the test is deterministic but still 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