Skip to content

WebSocket: cap the 101 handshake head on the completed parse path - #41569

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/edc486be/ws-head-cap-done-path
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/edc486be/ws-head-cap-done-path

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new WebSocket() accepts a 101 handshake head larger than the cap (--max-http-header-size, default 16 KB) whenever no recv() boundary lands inside the incomplete head. A 60 KB head split so the first read stays under the cap opens the connection.
  • The cap is checked only in the ShortRead arm of buffer_and_parse_head (src/http_jsc/websocket_client/WebSocketUpgradeClient.rs:717). The Ok(done) arm, which runs when the head parse completes, never checks the size. So the bound depends on read segmentation, not on the head size.

Fix

  • Check the cap in the Ok(done) arm too. Compare head_len (the header size alone), so pipelined WebSocket frames after the head are not counted.
  • Correct because the cap now applies to the header bytes on every path, not only when a read boundary splits an incomplete head. This matches the existing ShortRead check and Node and undici, which enforce a fixed 16 KB regardless of segmentation.
  • Verified: test/js/web/websocket/websocket-client-short-read.test.ts (new case, stock bun opens the connection and fails). Also ran the other 4 cases in that file, including the pipelined large-frame case that must still open.

Background

  • A WebSocket client first sends an HTTP/1.1 upgrade request, then reads the 101 response head. buffer_and_parse_head accumulates bytes and runs picohttp::Response::parse.
  • parse returns ShortRead while no \r\n\r\n is found, and Done once the head is complete. head_len (response.bytes_read) is the head size. Bytes after it are pipelined WebSocket frames.
  • max_http_header_size (default 16 KB) bounds the head so a peer cannot force unbounded buffering.

[human-review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/websocket/websocket-client-short-read.test.ts
bun test v1.4.3 (f42e98025)

test/js/web/websocket/websocket-client-short-read.test.ts:
(pass) WebSocket > short read on payload length [99.64ms]
(pass) WebSocket upgrade split across reads > large frame pipelined after split 101 header is not counted against the header-size cap [108.04ms]
239 |       },
240 |     });
241 | 
242 |     const { promise, resolve, reject } = Promise.withResolvers<string>();
243 |     const ws = new globalThis.WebSocket(`ws://127.0.0.1:${server.port}`);
244 |     ws.onopen = () => reject(new Error("unexpected open"));
                                       ^
error: unexpected open
      at <anonymous> (/workspace/bun/test/js/web/websocket/websocket-client-short-read.test.ts:244:34)
(fail) WebSocket upgrade split across reads > completed header larger than the cap is rejected even when split under the cap [102.86ms]
(pass) WebSocket upgrade split across reads > incomplete header larger than the cap is still rejected [84.91ms]
(pass) WebSocket buffered 
... (truncated)

release without fix: 1 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/js/web/websocket/websocket-client-short-read.test.ts:
(pass) WebSocket > short read on payload length [2.92ms]
(pass) WebSocket upgrade split across reads > large frame pipelined after split 101 header is not counted against the header-size cap [51.35ms]
239 |       },
240 |     });
241 | 
242 |     const { promise, resolve, reject } = Promise.withResolvers<string>();
243 |     const ws = new globalThis.WebSocket(`ws://127.0.0.1:${server.port}`);
244 |     ws.onopen = () => reject(new Error("unexpected open"));
                                       ^
error: unexpected open
      at <anonymous> (/workspace/bun/test/js/web/websocket/websocket-client-short-read.test.ts:244:34)
(fail) WebSocket upgrade split across reads > completed header larger than the cap is rejected even when split under the cap [53.38ms]
(pass) WebSocket upgrade split across reads > incomplete header larger than the cap is still rejected [51.21ms]
(pass) WebSocket buffered handshake data > terminating the client from its open handler while handshake bytes are buffered shuts down cleanly [13.24ms]

 4 pass
 1 fail
 5 expect() calls
Ran 5 tests across 1 fi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/websocket/websocket-client-short-read.test.ts
bun test v1.4.3 (f42e98025)

test/js/web/websocket/websocket-client-short-read.test.ts:
(pass) WebSocket > short read on payload length [81.49ms]
(pass) WebSocket upgrade split across reads > large frame pipelined after split 101 header is not counted against the header-size cap [1390.69ms]
(pass) WebSocket upgrade split across reads > completed header larger than the cap is rejected even when split under the cap [80.45ms]
(pass) WebSocket upgrade split across reads > incomplete header larger than the cap is still rejected [83.33ms]
(pass) WebSocket buffered handshake data > terminating the client from its open handler while handshake bytes are buffered shuts down cleanly [595.38ms]

 5 pass
 0 fail
 6 expect() calls
Ran 5 tests across 1 file. [8.24s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 7283ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/2355] host-cxx deps/icu/host-obj/source/i18n/reldtfmt.cpp.o
[2/2355] host-cxx deps/icu/host-obj/source/i18n/regexcmp.cpp.o
[3/2355] host-cxx deps/icu/host-obj/source/i18n/regeximp.cpp.o
[4/2355] host-cxx deps/icu/host-obj/source/i18n/regexst.cpp.o
[5/2355] host-cxx deps/icu/host-obj/source/i18n/regextxt.cpp.o
[6/2355] host-cxx deps/icu/host-obj/source/i18n/remtrans.cpp.o
[7/2355] host-cxx deps/icu/host-obj/source/i18n/rematch.cpp.o
[8/2355] host-cxx deps/icu/host-obj/source/i18n/rbnf.cpp.o
[9/2355] host-cxx deps/icu/host-obj/source/i18n/region.cpp.o
[10/2355] host-cxx deps/icu/host-obj/source/i18n/rbtz.cpp.o
[11/2355] host-cxx deps/icu/host-obj/source/i18n/reldatefmt.cpp.o
[12/2355] host-cxx deps/icu/host-obj/source/i18n/repattrn.cpp.o
[13/2355] host-cxx deps/icu/host-obj/source/i18n/selfmt.cpp.o
[14/2355] host-cxx deps/icu/host-obj/source/i18n/scriptset.cpp.o
[15/2355] host-cxx deps/icu/host-obj/source/i18n/scientificnumberformatter.cpp.o
[16/2355] host-cxx deps/icu/host-obj/source/i18n/sharedbreakite
... (truncated)
diff hotspot
.../websocket_client/WebSocketUpgradeClient.rs     |  3 ++
 .../websocket/websocket-client-short-read.test.ts  | 59 ++++++++++++++++++++++
 2 files changed, 62 insertions(+)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs       5      3      5
…st/js/web/websocket/websocket-client-short-read.test.ts      2      3      5

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 96245e26-f31e-478d-8ff5-f4e709cdb787

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and f9ac37e.

📒 Files selected for processing (2)
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • test/js/web/websocket/websocket-client-short-read.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

WebSocket handshake validation

Layer / File(s) Summary
Validate completed handshake headers
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs, test/js/web/websocket/websocket-client-short-read.test.ts
The client rejects parsed response headers larger than max_http_header_size(). The regression test sends an oversized handshake in two chunks and verifies that the connection does not open or deliver messages.

Suggested reviewers: alii, dylan-conway, jarred-sumner

Merge Risk: ⚪ Minimal · up to f9ac3

The change consistently rejects oversized WebSocket handshake headers while preserving pipelined frame handling, with no substantiated merge-blocking risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: enforcing the size cap for the completed WebSocket 101 handshake parse path.
Description check ✅ Passed The description explains the problem, fix, rationale, implementation scope, and verification results. It does not use the template headings exactly, but it provides the required information and is com…

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:01 AM PT - Sep 6th, 2026

❌ @robobun, your commit 8901702 has 4 failures in Build #111337 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41569

That installs a local version of the PR into your bun-41569 executable, so you can run:

bun-41569 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the completed-parse arm now enforces the same max_http_header_size() cap the ShortRead arm already had, using head_len so pipelined frame bytes aren't counted.

What was reviewed:

  • Confirmed the new guard compares head_len (response.bytes_read, header bytes only) with the same > operator and helper as the existing ShortRead check, and that arm ordering makes it take precedence over Ok(done).
  • Checked the neighboring "large frame pipelined after split 101 header" test still holds: its head is small and only the trailing frame is >16KB, so head_len stays under the cap and that case still opens.
  • New test mirrors the adjacent cap-rejection test's shape (using server, port: 0, Buffer.alloc, Promise.withResolvers, all failure events wired to reject) and the 16000/21000 split keeps the first read under the cap so the old ShortRead check never fires.
Extended reasoning...

Overview

The PR adds one match-arm guard to buffer_and_parse_head in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs so that a fully-parsed 101 response head whose head_len exceeds bun_http::max_http_header_size() is treated as HeadParse::Invalid. Previously the cap was only enforced in the ShortRead arm, so a large head that completed in a single accumulated parse (or whose intermediate buffered size never crossed the cap before the terminator arrived) was accepted. A new test in test/js/web/websocket/websocket-client-short-read.test.ts sends a ~21KB 101 head split so the first write is 16000 bytes (< 16384), and asserts the client fires onerror with "Invalid response" instead of opening.

Security risks

This is a hardening fix: it tightens an existing resource bound so it applies regardless of TCP read segmentation. The change only makes the client reject more inputs, never accept more, so there is no new attack surface. The comparison uses head_len (header bytes only) rather than full.len(), which correctly avoids counting pipelined WebSocket frame bytes against the header cap — verified against the adjacent test that pipelines a 20KB frame after a small header and must still open.

Level of scrutiny

Low-to-moderate. The Rust change is 3 effective lines that mirror the existing ShortRead arm's check byte-for-byte (same helper, same > operator). Match-arm ordering is correct (the guarded Ok arm precedes the catch-all Ok(done)). The changed paths are not covered by CODEOWNERS. The bug hunt ran to dry_streak with no findings and no ruled-out candidates.

Other factors

The new test is a near-exact copy of the neighboring "incomplete header larger than the cap is still rejected" test: using for the listener, port: 0, Buffer.alloc(n, fill) for padding, Promise.withResolvers, and every unexpected event (open, message, clean close) wired to reject. The setTimeout(.., 50) between writes matches the pattern used by both adjacent tests in this file for forcing a read boundary on loopback. On the unfixed build the first 16000-byte read passes the ShortRead cap check and the second read completes the head with no size check, so the connection opens and the test rejects with "unexpected open" — it fails for the right reason.

@robobun
robobun force-pushed the robobun/edc486be/ws-head-cap-done-path branch from 0b1d17b to 2ea0a8c Compare September 6, 2026 12:57
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/web/websocket/websocket-client-short-read.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the comment-narration nit from the last round is addressed, and the guard itself is a straight mirror of the existing ShortRead cap check.

What was reviewed:

  • buffer_and_parse_head: new Done arm compares head_len (picohttp bytes_read, header bytes only) against max_http_header_size(), so pipelined frame bytes in full aren't counted — consistent with the ShortRead arm's rationale comment.
  • Generic impl<SSL> covers both TLS and plain sockets, so no sibling site needs the same guard.
  • New test matches the file's existing split-write setTimeout pattern (lines 55/158/277) and wires onopen/onmessage/clean onclose to reject.
Extended reasoning...

Overview

The PR adds a single match-arm guard in WebSocketUpgradeClient::buffer_and_parse_head so that a fully-parsed 101 response head whose head_len exceeds bun_http::max_http_header_size() is treated as HeadParse::Invalid. Previously the cap was only enforced in the ShortRead arm, so a head that completed in one parse call — even if accumulated across multiple reads each individually under the cap — bypassed the limit. The accompanying test sends a ~21 KB head split at 16000 bytes and asserts the client fires onerror with "Invalid response".

Security risks

This is a resource-limit hardening change: it closes a gap where a peer could push an oversized handshake head past the buffering cap by segmenting writes. The fix compares response.bytes_read (header bytes only), not the full accumulated buffer, so it does not over-reject when pipelined WebSocket frames follow the head in the same read. No new unsafe code, no allocation, no user-input parsing beyond what picohttp already does.

Level of scrutiny

Low-to-moderate. The Rust change is three lines that exactly mirror the sibling ShortRead arm's cap check and return the same HeadParse::Invalid sentinel that the existing malformed-response arm uses, so downstream handling is already exercised. The function is generic over the SSL type parameter, so both TLS and plaintext paths share this code — no parallel site to patch.

Other factors

Since the previous review, commit 8901702 rewrote the test's block comment to describe the invariant (oversized head, first write under the cap) and dropped the "The old code…" diff narration I'd flagged. The new test copies the file's established conventions verbatim: Bun.listen with port: 0, Buffer.alloc(n, fill), the same server-side setTimeout split-write pattern used by three sibling tests in the file, Promise.withResolvers with all failure events (onopen, onmessage, clean onclose) wired to reject, and cleanup in finally. Nothing outstanding from other reviewers in the timeline beyond bot comments the author self-resolved, all of which correspond to the comment-wording commits.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the diff is ready for review.

Reproduced with the new case in test/js/web/websocket/websocket-client-short-read.test.ts. Stock bun opens the connection on a 21 KB 101 head split under the cap. With the fix the client closes with Invalid response. The file passes on every CI lane in build 111337.

The red lanes in that build are unrelated to this change. They are RSS leak tests and timeouts on the x64-asan and alpine lanes (html-rewriter-leak, require-cache, fetch-leak, test-crypto-dh-leak). Each is reported for triage on main. The remaining failures passed on retry.

robobun added a commit that referenced this pull request Sep 18, 2026
The scratch was sized from the line count of the response head alone.
With a raised --max-http-header-size, a server could make each read of
an incomplete head allocate 16 bytes of scratch per buffered byte.

Clamp the slot count to 2000, the default of Node's maxHeadersCount, so
the scratch is at most 64 KB whatever the header size cap is. A head with
more fields fails the parse as before. The clamp makes the head size
check before the parse unnecessary, so the ShortRead check is back as it
was on main and a head over the size cap that completes in one read is
again left to #41569.
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #41573. It carries the same change to buffer_and_parse_head, adds the matching fix for the fetch() response head cap, and has this PR's test case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant