From 7a2fd3db7e839368ed820c96c71bdf58fce25f3a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:36:49 +0000 Subject: [PATCH 1/3] fetch: reject non-hex junk in chunked chunk-size instead of reading it as zero phr_decode_chunked stopped at the first non-hex byte in the chunk-size token and fell through to CHUNKED_IN_CHUNK_EXT, which scans to LF without validating. A size line like "0x5\r\n" was therefore read as size 0 (last chunk) and fetch() resolved 200 with an empty body while the 5 payload bytes were still on the wire. node/llhttp reject this with HPE_INVALID_CHUNK_SIZE. After the hex digits, only ';' (chunk-ext), CR, or LF are valid per RFC 9112 7.1. Anything else now returns -1, surfaced as InvalidHTTPResponse, matching llhttp strict mode. Applied as a patch on the vendored picohttpparser. --- .../picohttpparser/strict-chunk-size.patch | 15 ++++++ scripts/build/deps/picohttpparser.ts | 2 + test/js/web/fetch/chunked-trailing.test.js | 48 ++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 patches/picohttpparser/strict-chunk-size.patch diff --git a/patches/picohttpparser/strict-chunk-size.patch b/patches/picohttpparser/strict-chunk-size.patch new file mode 100644 index 000000000000..ece6f7c902a6 --- /dev/null +++ b/patches/picohttpparser/strict-chunk-size.patch @@ -0,0 +1,15 @@ +--- a/picohttpparser.c ++++ b/picohttpparser.c +@@ -557,6 +557,12 @@ + ret = -1; + goto Exit; + } ++ /* chunk-size is 1*HEXDIG followed by chunk-ext (";") or CRLF; reject anything ++ * else so tokens like "0x5" are not misread as the last chunk */ ++ if (buf[src] != ';' && buf[src] != '\015' && buf[src] != '\012') { ++ ret = -1; ++ goto Exit; ++ } + break; + } + if (decoder->_hex_count == sizeof(size_t) * 2) { diff --git a/scripts/build/deps/picohttpparser.ts b/scripts/build/deps/picohttpparser.ts index 5b5dea6d1ba0..2c65d9d3632f 100644 --- a/scripts/build/deps/picohttpparser.ts +++ b/scripts/build/deps/picohttpparser.ts @@ -22,6 +22,8 @@ export const picohttpparser: Dependency = { commit: PICOHTTPPARSER_COMMIT, }), + patches: ["patches/picohttpparser/strict-chunk-size.patch"], + build: () => ({ kind: "none" }), provides: () => ({ diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index 9e458160ad40..d179d01b8fff 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -1,4 +1,4 @@ -import { expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import net from "node:net"; it("handles trailing headers split across packets", async () => { @@ -640,6 +640,52 @@ it("proper error if missing data in middle of chunk extension", async () => { } }); +describe("rejects malformed chunk-size token", () => { + // RFC 9112 §7.1: chunk-size is 1*HEXDIG followed by ";" (chunk-ext) or CRLF. + // node/llhttp rejects all of these with HPE_INVALID_CHUNK_SIZE. + it.each([ + ["0x5", ""], // previously misread as size 0: fetch resolved with an empty body + ["5g", "hello"], // previously misread as size 5 + ["5 ", "hello"], + ["5\t", "hello"], + ["5.0", "hello"], + ["5-", "hello"], + ])("token %j", async (token, _previouslyResolvedAs) => { + const { promise, resolve } = Promise.withResolvers(); + await using server = net + .createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end(`HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n${token}\r\nhello\r\n0\r\n\r\n`); + }); + }) + .listen(0, "127.0.0.1", () => resolve(server.address())); + + const address = await promise; + const err = await fetch(`http://127.0.0.1:${address.port}/`) + .then(res => res.text()) + .then(body => ({ resolved: body })) + .catch(e => e); + expect(err?.code).toBe("InvalidHTTPResponse"); + }); + + it("still accepts a chunk-ext immediately after the size", async () => { + const { promise, resolve } = Promise.withResolvers(); + await using server = net + .createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;ext=1\r\nhello\r\n0\r\n\r\n"); + }); + }) + .listen(0, "127.0.0.1", () => resolve(server.address())); + + const address = await promise; + const res = await fetch(`http://127.0.0.1:${address.port}/`); + expect(await res.text()).toBe("hello"); + }); +}); + it("proper error if missing CRLF after chunk data", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net From ea6fa3a524790b2e96b2d265dab5a40403118d57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:51 +0000 Subject: [PATCH 2/3] test: move chunk-size validation tests to a dedicated file chunked-trailing.test.js binds its raw servers to "localhost" which races against IPv6-first resolution in some environments. The new chunk-size tests bind to 127.0.0.1 explicitly and live in their own file so the gate runs only the cases that exercise this fix. --- test/js/web/fetch/chunked-trailing.test.js | 48 +---------------- test/js/web/fetch/fetch-chunked-size.test.ts | 55 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 test/js/web/fetch/fetch-chunked-size.test.ts diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index d179d01b8fff..9e458160ad40 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { expect, it } from "bun:test"; import net from "node:net"; it("handles trailing headers split across packets", async () => { @@ -640,52 +640,6 @@ it("proper error if missing data in middle of chunk extension", async () => { } }); -describe("rejects malformed chunk-size token", () => { - // RFC 9112 §7.1: chunk-size is 1*HEXDIG followed by ";" (chunk-ext) or CRLF. - // node/llhttp rejects all of these with HPE_INVALID_CHUNK_SIZE. - it.each([ - ["0x5", ""], // previously misread as size 0: fetch resolved with an empty body - ["5g", "hello"], // previously misread as size 5 - ["5 ", "hello"], - ["5\t", "hello"], - ["5.0", "hello"], - ["5-", "hello"], - ])("token %j", async (token, _previouslyResolvedAs) => { - const { promise, resolve } = Promise.withResolvers(); - await using server = net - .createServer(socket => { - socket.on("error", () => {}); - socket.once("data", () => { - socket.end(`HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n${token}\r\nhello\r\n0\r\n\r\n`); - }); - }) - .listen(0, "127.0.0.1", () => resolve(server.address())); - - const address = await promise; - const err = await fetch(`http://127.0.0.1:${address.port}/`) - .then(res => res.text()) - .then(body => ({ resolved: body })) - .catch(e => e); - expect(err?.code).toBe("InvalidHTTPResponse"); - }); - - it("still accepts a chunk-ext immediately after the size", async () => { - const { promise, resolve } = Promise.withResolvers(); - await using server = net - .createServer(socket => { - socket.on("error", () => {}); - socket.once("data", () => { - socket.end("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;ext=1\r\nhello\r\n0\r\n\r\n"); - }); - }) - .listen(0, "127.0.0.1", () => resolve(server.address())); - - const address = await promise; - const res = await fetch(`http://127.0.0.1:${address.port}/`); - expect(await res.text()).toBe("hello"); - }); -}); - it("proper error if missing CRLF after chunk data", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net diff --git a/test/js/web/fetch/fetch-chunked-size.test.ts b/test/js/web/fetch/fetch-chunked-size.test.ts new file mode 100644 index 000000000000..4e306f3fa4f1 --- /dev/null +++ b/test/js/web/fetch/fetch-chunked-size.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import net from "node:net"; + +async function serveChunked(body: string) { + const { promise, resolve } = Promise.withResolvers(); + const server = net + .createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end(`HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n${body}`); + }); + }) + .listen(0, "127.0.0.1", () => resolve(server.address() as net.AddressInfo)); + const address = await promise; + return { server, url: `http://127.0.0.1:${address.port}/` }; +} + +describe("fetch: chunked chunk-size token validation", () => { + // RFC 9112 7.1: chunk-size is 1*HEXDIG followed by ";" (chunk-ext) or CRLF. + // node/llhttp rejects every token below with HPE_INVALID_CHUNK_SIZE. + describe("rejects malformed chunk-size", () => { + it.each([ + ["0x5", ""], // was misread as size 0: resolved 200 with empty body, data dropped + ["5g", "hello"], // was misread as size 5 + ["5 ", "hello"], + ["5\t", "hello"], + ["5.0", "hello"], + ["5-", "hello"], + ])("token %j", async (token, _previouslyResolvedAs) => { + const { server, url } = await serveChunked(`${token}\r\nhello\r\n0\r\n\r\n`); + await using _s = server; + const result = await fetch(url) + .then(res => res.text()) + .then(body => ({ resolved: body })) + .catch(e => e); + expect(result?.code).toBe("InvalidHTTPResponse"); + }); + }); + + describe("accepts well-formed chunk-size", () => { + it.each([ + ["5", "hello"], + ["5;ext", "hello"], + ["5;ext=1", "hello"], + ["05", "hello"], + ["A", "0123456789"], + ])("token %j", async (token, payload) => { + const { server, url } = await serveChunked(`${token}\r\n${payload}\r\n0\r\n\r\n`); + await using _s = server; + const res = await fetch(url); + expect(await res.text()).toBe(payload); + expect(res.status).toBe(200); + }); + }); +}); From 7056951482ee7979efa84b59250f19ee676fec6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:06:21 +0000 Subject: [PATCH 3/3] ci: retrigger