websocket: only cap header bytes, not pipelined frames, during upgrade
Problem
The hardening pass in #31175 added a max_http_header_size cap (default 16384) to the WebSocket upgrade client's response accumulator at all three entry points (handle_data, handle_proxy_response, handle_decrypted_data). The cap was applied to total bytes received before parsing:
if !me.body.is_empty() {
if me.body.len().saturating_add(data.len()) > bun_http::max_http_header_size() {
Self::terminate(this, ErrorCode::InvalidResponse);
return;
}
me.body.extend_from_slice(data);
}
This counts any WebSocket frame bytes the server pipelines after the 101 response in the same TCP segment as the header tail. Failing sequence:
- First read delivers a partial 101 status line → picohttp
ShortRead→ buffered toself.body - Second read delivers rest-of-header plus a >16KB initial WS frame →
body.len() + data.len() > 16384→terminate(InvalidResponse)
The Zig reference (WebSocketUpgradeClient.zig lines 594-598 / 616-620 / 631-634 / 656-658 / 802-805 / 824-826) has no size cap; it appends and continues, parses the header, and hands the trailing bytes to processResponse as remain_buf.
Reproduction
error: ws error: WebSocket connection to 'ws://127.0.0.1:.../' failed: Invalid response
Raw TCP server that writes "HTTP/1.1 101 ", flushes, then after a short delay writes the header tail plus a 20000-byte binary frame in one packet. 10/10 failures on the released build.
Fix
Move the cap to the ShortRead arm, where picohttp has confirmed no \r\n\r\n exists and therefore every byte in the accumulator is provably header:
Err(picohttp::ParseResponseError::ShortRead) => {
if me.body.is_empty() {
me.body.extend_from_slice(data);
}
if me.body.len() > bun_http::max_http_header_size() {
Self::terminate(this, ErrorCode::InvalidResponse);
}
return;
}
Applied identically to handle_data, handle_proxy_response, and handle_decrypted_data. This keeps the DoS protection from #31175 (a server that never sends \r\n\r\n is still rejected once the accumulator exceeds 16KB) while matching the Zig reference for well-formed responses that pipeline large frames. Worst-case allocation before rejection is max_http_header_size + one socket read, bounded by the kernel recv buffer.
Tests
Added to test/js/web/websocket/websocket-client-short-read.test.ts:
large frame pipelined after split 101 header is not counted against the header-size cap: fails withInvalid responseon the unfixed build, passes with the fix (verified 5x stable).incomplete header larger than the cap is still rejected: confirms the retained DoS guard still fires when 20KB of header bytes arrive with no terminator.