Apply the response head size cap to the parsed head, not only to short reads
Problem
fetch()caps the response head at 1 MiB (ResponseHeadersTooLarge), only in the picohttpShortReadarm ofhandle_on_data_headers(src/http/lib.rs:3738). A read that crosses 1 MiB and delivers the blank line skips the check.new WebSocket()has the same gap inbuffer_and_parse_head(src/http_jsc/websocket_client/WebSocketUpgradeClient.rs:723) for its 16 KB cap. The cap is also on raw bytes: Node opens a 16400 byte 101 head that Bun rejects when a read ends past 16384.
Fix
fetch: after a successful parse, reject whenparsed.bytes_readexceeds the cap. WebSocket: both arms compare the head withmax_http_header_size() + 128 * 4 + 64, the raw bound bun-uws uses for request heads. An over-size head fails withResponse headers exceed --max-http-header-size.- Behavior change: a 101 head, a proxy CONNECT reply, or a tunnelled 101 head over the bound now always fails. Before, it opened when no read ended inside it. Node rejects such a head with
HPE_HEADER_OVERFLOW. - Verified:
test/js/web/fetch/fetch.test.ts(3 cases),test/js/web/websocket/websocket-client-short-read.test.ts(4 new, 1 updated). Stock bun fails 2 and 4 of them. - Self-reviewed: 4 concerns raised, 3 addressed. Open: a maintainer decision on the behavior change.
Background
- picohttp parses the head from the accumulated bytes.
ShortReadmeans no blank line yet, so the client buffers and parses again on the next read. on_datadelivers whatrecv()returned, up to 512 KiB. The peer's writes and timing decide where a read ends.max_http_header_sizeis--max-http-header-size. Node applies it to the bytes llhttp reports (status text, field names, values), not to framing (": ","\r\n").
Notes
What changes for real traffic. The fetch() half only affects heads over 1 MiB. The WebSocket half is the one with reach. On main, a head over the cap opens whenever no read ends inside the incomplete head with more than 16384 bytes buffered. Examples: a head of up to 512 KiB that arrives in one read, and a 21000 byte head whose first read is 16000 bytes. With this PR every head over max_http_header_size() + 576 fails, at any segmentation. A head at or under that bound opens at any segmentation. On main a 16400 byte head fails when a read ends past 16384, so the window between 16384 and the bound is now more lenient than main. buffer_and_parse_head has three callers, so the bound also applies to the proxy CONNECT reply and to the 101 head inside a TLS tunnel. --max-http-header-size raises the bound. With --max-http-header-size=32768 a 20000 byte head opens.
Node comparison (v26.3.0, http.request upgrade, one write from a raw TCP server, same head layout as the tests): a head of 16416 raw bytes or less opens. 16417 and above fail with HPE_HEADER_OVERFLOW. Node's TrackHeader adds the lengths of the status text, the field names and the values, and fails at 16384. For this head the framing is 33 bytes, which gives the 16417 threshold. This branch opens up to 16960 raw bytes and rejects 16961 and above. So it is never stricter than Node for up to 128 fields, and at most 576 bytes more lenient.
Why a raw bound with slack. packages/bun-uws/src/HttpParser.h does the same for request heads: MAX_HEADER_FRAMING_SLACK = UWS_HTTP_MAX_HEADERS_COUNT * 4 + 64, with the comment that raw bounds get the framing as slack "so we don't reject requests Node accepts". The result stays a function of the raw head length alone, which keeps it independent of where reads end. If the field limit of this client grows (#43243 proposes 2000), the slack must follow it.
History. #31175 added the cap to the accumulator, before the parse, once buffering had begun. #32394 moved it into the ShortRead arm so that frames pipelined after the head are not counted. That left a complete head unchecked. This PR checks head_len, so pipelined frames are still not counted.
Intentionally not changed. handle_proxy_response rejects a CONNECT reply early when the first bytes are not HTTP/1.1 200 or HTTP/1.0 200 . That check looks at the status line prefix, not at the head size. It has its own dependence on read boundaries: a 407 reply reports Proxy connection failed in one read and Proxy authentication required when the first read has 13 bytes or fewer. That is a separate bug and this PR leaves it alone. Moving the cap into picohttp::Response::parse_parts is a possible follow-up.
Open question for a maintainer. No maintainer has said that a complete head over the bound must be rejected. Node (through llhttp) and the bun-uws request parser reject it. If the preferred policy for the WebSocket client is a growth bound only, the Ok arm check can be dropped and the rest of the PR still stands.
Fail-before. Stock bun (1.4.3-canary.1+b52d51348):
fetch.test.ts: accepts a head of 1 MiB + 1 byte in one write and in 4 KiB writes. A head of cap + 1 bytes can never hit the old short-read check, because no incomplete prefix is longer than the cap. The exact 1 MiB case is a control.websocket-client-short-read.test.ts: opens a 16961 byte head, opens a 21000 byte head whose first read is 16000 bytes, rejects a 16400 byte head whose first read is 16390 bytes, and reportsInvalid responsefor an incomplete 20 KB head. The 16960 byte case is a control.
Also ran (debug build with ASAN): websocket-client.test.ts, websocket-upgrade.test.ts, websocket-handshake-event.test.ts, websocket-custom-headers.test.ts, websocket-accept-header-validation.test.ts, websocket-proxy.test.ts, error-event.test.ts, test/js/bun/http/proxy-stress-protocol.test.ts. All pass. websocket-client-short-read.test.ts passed 5 runs in a row.
The full fetch.test.ts run under the ASAN debug build also fails a set of tests that need public internet (the container proxy denies it), root-sensitive permission tests, and a few (with gc) tests that time out at 5 s. These fail without this change too. should allow very long redirect URLS needs 5.6 s on the debug build (200 forced GCs) and passes with a larger timeout.
Trial merges of this branch with #43243 and with #43181 are clean.
no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts