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/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); + }); + }); +});