websocket: only cap header bytes, not pipelined frames, during upgrade - #32394
Conversation
The hardening pass in #31175 added a max_http_header_size cap to the WebSocket upgrade client's response accumulator. The cap was applied to total bytes received before parsing, so when a server sends a partial 101 header followed by the header tail plus a >16KB initial WebSocket frame in the next TCP segment, body.len() + data.len() exceeded the 16KB default and the connection was terminated with InvalidResponse even though the header itself was small. Move the cap to the ShortRead arm (where picohttp has confirmed no \r\n\r\n exists, so every buffered byte is header) and check the accumulated header length there. Applied to all three entry points: handle_data, handle_proxy_response, handle_decrypted_data. The Zig reference has no cap at all; this retains the DoS protection from #31175 against servers that never terminate the header while matching Zig behavior for well-formed responses with large pipelined frames.
|
Updated 5:13 AM PT - Jun 16th, 2026
✅ @robobun, your commit ad3dc7d226328f5b6927f0d8826bec63c935ef41 passed in 🧪 To try this PR locally: bunx bun-pr 32394That installs a local version of the PR into your bun-32394 --bun |
WalkthroughIn ChangesWebSocket upgrade ShortRead header-size cap refactor
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/websocket/websocket-client-short-read.test.ts`:
- Around line 158-172: The test uses setTimeout with a fixed delay to schedule
the second socket write, which creates time-dependent, flaky behavior under CI
load. Replace both setTimeout calls (one at lines 158-172 and another at lines
218-223) with a deterministic, condition-based approach such as waiting for an
event emitted by the socket, a promise that resolves when the connection is
ready, or another non-wall-clock trigger that indicates when the second write
should proceed. This ensures the test behaves consistently regardless of system
load and CI environment variations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 303bf34d-84b3-401e-90a3-0f466ffbdf2d
📒 Files selected for processing (2)
src/http_jsc/websocket_client/WebSocketUpgradeClient.rstest/js/web/websocket/websocket-client-short-read.test.ts
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — targeted fix that moves the header-size cap to the ShortRead arm where bytes are provably header, with regression tests for both the fix and the retained DoS guard.
Extended reasoning...
Overview
This PR fixes a regression from #31175 in the Rust WebSocket upgrade client. The header-size cap was being applied to total bytes received before parsing, which incorrectly counted pipelined WebSocket frame bytes against the 16KB header limit when a server split the 101 response across reads and appended a large initial frame to the second segment. The fix moves the cap check from the pre-parse accumulation step to the ShortRead arm of the picohttp parse result — at which point picohttp has confirmed no \\r\\n\\r\\n exists, so every accumulated byte is provably header. The same edit is applied consistently to all three entry points (handle_data, handle_proxy_response, handle_decrypted_data).
Security risks
The original cap was DoS hardening. The fix preserves it: a server that streams header bytes without ever sending \\r\\n\\r\\n still hits ShortRead on every read, accumulates into me.body, and is terminated once the buffer exceeds max_http_header_size. The second new test (incomplete header larger than the cap is still rejected) directly exercises this. The only behavioral relaxation is that we now extend_from_slice before checking the cap rather than after, so worst-case transient allocation is max_http_header_size + one socket read — bounded by the kernel recv buffer and explicitly called out in the PR description. This matches the Zig reference implementation, which has no cap at all on this path. No new attack surface.
Level of scrutiny
Moderate. This is network-facing client code with unsafe blocks, but the diff is a mechanical relocation of an existing check within the same function bodies — control flow, the terminate SAFETY invariants, and the return placement are unchanged. The reasoning (ShortRead ⇒ no header terminator ⇒ all bytes are header) is sound and well-commented inline.
Other factors
No CODEOWNERS entry covers src/http_jsc/websocket_client/. The bug-hunting system found no issues. Two focused regression tests are added: one reproducing the original failure (split 101 + 20KB pipelined frame) and one confirming the DoS guard still fires. No prior reviews or outstanding comments on the PR.
|
@robobun Can you figure out if this fixes 28599? |
|
No, this doesn't fix #28599. Different error code. #28599 reports Different precondition. Different timeline. #28599 regressed between 1.3.5 and 1.3.11. The cap this PR relaxed was introduced by #31175, which merged after 1.3.11. So this PR is undoing a change that didn't exist when #28599 broke. The commits in the 1.3.5→1.3.11 window that actually plausibly cause #28599 are c90c0e6 (added WebSocket proxy support, introducing the |
Problem
The hardening pass in #31175 added a
max_http_header_sizecap (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:This counts any WebSocket frame bytes the server pipelines after the 101 response in the same TCP segment as the header tail. Failing sequence:
ShortRead→ buffered toself.bodybody.len() + data.len() > 16384→terminate(InvalidResponse)The Zig reference (
WebSocketUpgradeClient.ziglines 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 toprocessResponseasremain_buf.Reproduction
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
ShortReadarm, where picohttp has confirmed no\r\n\r\nexists and therefore every byte in the accumulator is provably header:Applied identically to
handle_data,handle_proxy_response, andhandle_decrypted_data. This keeps the DoS protection from #31175 (a server that never sends\r\n\r\nis 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 ismax_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.