websocket: only cap header bytes, not pipelined frames, during upgrade

Merged
RO
5mo ago
+165-30

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:

  1. First read delivers a partial 101 status line → picohttp ShortRead → buffered to self.body
  2. 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 with Invalid response on 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.