Skip to content

websocket: fail the client handshake on a missing subprotocol or an invalid Sec-WebSocket-Extensions response - #32825

Open
robobun wants to merge 9 commits into
mainfrom
farm/fe2a6268/ws-client-handshake-validation
Open

websocket: fail the client handshake on a missing subprotocol or an invalid Sec-WebSocket-Extensions response#32825
robobun wants to merge 9 commits into
mainfrom
farm/fe2a6268/ws-client-handshake-validation

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

RFC 6455 section 4.1 and the WHATWG "establish a WebSocket connection" algorithm both require the client to fail the WebSocket connection when the server's 101 response either:

  1. does not select one of the subprotocols the client requested, or
  2. indicates an extension the client's handshake did not offer.

RFC 7692 section 8.1 adds a third case for the one extension Bun offers: a permessage-deflate response that carries a parameter not defined for use in a response, a repeated parameter, or a parameter with an invalid value must also fail the connection.

Bun's client opened the connection in all of these cases. The parameter one is the dangerous one: extension parameters define how subsequent frames are compressed, and an accepted-but-ignored server_max_window_bits=20 (out of range, so Bun silently kept its default of 15) means a conforming server can compress with a window the client never agreed to. That surfaces later as inflate failures and abrupt 1006 disconnects instead of a clean handshake failure at the one moment it is debuggable.

A fourth, related bug: the subprotocol-mismatch failure was the only handshake failure that skipped the error event and reported wasClean: true.

Repro

Against a raw TCP server that replies with a valid 101 plus the crafted header (node ws, Chrome, and Firefox fail all of these):

// 1. server omits Sec-WebSocket-Protocol entirely
new WebSocket(url, ["aaa", "bbb"]);
// Bun: fires open, ws.protocol === ""

// 2. server replies `Sec-WebSocket-Extensions: x-bogus-ext` (never offered)
new WebSocket(url);
// Bun: fires open

// 3. server replies `Sec-WebSocket-Extensions: permessage-deflate; bogus_param=1`
new WebSocket(url);
// Bun: fires open

// 4. server replies `Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=20`
new WebSocket(url);
// Bun: fires open and inflates with the 15-bit default the server did not pick

// 5. server replies `Sec-WebSocket-Protocol: zzz` (never offered)
new WebSocket(url, ["aaa", "bbb"]);
// Bun: no error event, close { code: 1002, wasClean: true }

After this PR, Bun fails 1 through 4 with an error event followed by a close event with wasClean: false and a descriptive 1002 reason, and 5 gets the same event semantics as every other handshake failure.

Cause

  • process_response tracked protocol_header_seen but never checked it after the header loop, so "client requested protocols, server confirmed none" was never detected. The missing_client_protocol error code had existed in the enum the whole time with nothing emitting it. Hardening round 11 (Hardening round 11: input validation, bounds checks, lifetimes #33072) has since landed that detection on main, so it is no longer part of this diff; it also means the broken close semantics in the third bullet below became reachable for the first time.
  • The Sec-WebSocket-Extensions guard only fired when the client offered no extensions. The default client offers permessage-deflate, so any other token in the response was silently ignored. The parameter loop had no rejection path at all: unknown keys fell through an if/else chain with no final else, and a missing, malformed, or out-of-range *_max_window_bits value just skipped the assignment and left the default.
  • In WebSocket::didFailWithErrorCode, the missing_client_protocol and mismatch_client_protocol arms passed CleanStatus::Clean and omitted isConnectionError, unlike every sibling handshake-failure arm, so no error event fired and CloseEvent.wasClean was true.

What does this PR do?

src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

  • The inline extensions loop is replaced by accept_extensions_response(): the response must contain exactly one permessage-deflate element (RFC 7692 section 5 forbids accepting it twice, whether in one header value or across several headers), and its parameters must be the four RFC 7692 defines, each at most once. server_no_context_takeover and client_no_context_takeover take no value. server_max_window_bits and client_max_window_bits require a decimal value between 8 and 15 in a response (only an offer may omit the value). Anything else terminates the handshake.
  • The happy path is unchanged: an accepted response still lands in DeflateNegotiationResult, ws.extensions, and the inflater.

src/http_jsc/websocket_client.rs, src/jsc/bindings/webcore/WebSocketErrorCode.h, src/jsc/bindings/webcore/WebSocket.cpp

  • New invalid_extensions_header error code, so extension failures close with 1002 and the reason Invalid Sec-WebSocket-Extensions header (the wording ws uses) instead of the generic Invalid response.
  • missing_client_protocol and mismatch_client_protocol now use CleanStatus::NotClean and isConnectionError = true like the other handshake-failure arms: the error event fires and wasClean is false. The previously unreachable missing_client_protocol reason string is now "Server sent no subprotocol" (the wording ws uses).

This cannot reject Bun's own server: the Sec-WebSocket-Extensions line a Bun.serve 101 produces is permessage-deflate plus an optional client_no_context_takeover or client_max_window_bits=N and an optional server_no_context_takeover or server_max_window_bits=N, always valued and in range (packages/bun-uws/src/WebSocketExtensions.h).

Not changed, on purpose. Two more divergences from the same report are deliberate, tested Bun behaviors, so I left them for a maintainer call rather than bundling a breaking change in here:

  • Bun reports handshake failures as close code 1002 with a descriptive reason; the spec and Node use 1006 with an empty reason. Every existing test in websocket-subprotocol-strict.test.ts asserts the 1002 + reason shape.
  • binaryType defaults to "nodebuffer" and throws SyntaxError on an out-of-enum assignment; the spec says "blob" and silently ignore. Both are explicitly asserted in websocket-client.test.ts.

How did you verify your code works?

test/js/web/websocket/websocket-subprotocol-strict.test.ts: two missing-subprotocol cases, a "strict RFC 6455 extension handling" describe (six rejections plus three should-still-succeed controls), and the shared expectConnectionFailure helper asserts exactly one error event, wasClean: false, and the close reason, and rejects the instant open fires. The eleven pre-existing subprotocol-mismatch tests thereby become the regression tests for the event-semantics fix.

test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts: a "Sec-WebSocket-Extensions response validation" describe against a byte-controlled raw 101 that also sends a frame, alongside the RSV1 frame tests main added in #33395. Twenty invalid responses (unoffered extension names, a duplicated element, unknown parameters, out-of-range, malformed, missing, signed, separator-bearing, leading-zero, and repeated window bits, valued no_context_takeover) must produce no open, no message, and a 1002 close with the new reason. Five valid responses, including a quoted parameter value and no extensions header at all, must open, deliver the frame, and report the negotiated ws.extensions. A final case round-trips a real RSV1 deflate frame after negotiation.

Against the rebased base (src/ reverted to main, tests from this branch, which is what the two files prove between them):

bun bd test websocket-subprotocol-strict.test.ts websocket-permessage-deflate-edge-cases.test.ts
# src/ at main:  40 fail, 29 pass
# this branch:   69 pass,  0 fail

Every in-tree test that passes a client subprotocol connects to a server that echoes one back (Bun.serve and ws.Server both select the first offered protocol by default), so none are affected by the new check. The adjacent suites (regression/issue/29684.test.ts, websocket-permessage-deflate*.test.ts, websocket-custom-headers.test.ts, websocket-accept-header-validation.test.ts, websocket-close-connecting.test.ts, websocket-proxy.test.ts, test-ws-bidir-proxy.test.ts, websocket.test.js, first_party/ws/ws.test.ts) have the same pass/fail set before and after the change; the handful of pre-existing failures in them (external-network hosts, the 30 documented timeouts in ws.test.ts) are identical with src/ stashed.

Rebase notes

Rebased onto main after several WebSocket changes landed there. The conflicts and how they were resolved:

The full WebSocket suite passes on the rebased branch, plus the pre-existing external-network and proxy-sandbox failures noted above, unchanged.


[review] gate passed · iteration 20 · 6 files touched

fails on main (without fix)
ASAN without fix: 40 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts test/js/web/websocket/websocket-subprotocol-strict.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f4a143933)

test/js/web/websocket/websocket-subprotocol-strict.test.ts:
90 |   ws.onerror = onerrorMock;
91 |   ws.onclose = resolveClose;
92 | 
93 |   try {
94 |     const close = await closePromise;
95 |     expect(onerrorMock).toHaveBeenCalledTimes(1);
                             ^
error: expect(received).toHaveBeenCalledTimes(expected)

Expected number of calls: 1
Received number of calls: 0

      at expectConnectionFailure (/workspace/bun/test/js/web/websocket/websocket-subprotocol-strict.test.ts:95:25)
      at async <anonymous> (/workspace/bun/test/js/web/websocket/websocket-subprotocol-strict.test.ts:124:11)
(fail) WebSocket stri
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (97a17c79e)

test/js/web/websocket/websocket-subprotocol-strict.test.ts:
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols [7.45ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols with spaces [1.87ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols (3 protocols) [1.71ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject duplicate Sec-WebSocket-Protocol headers (same value) [1.61ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject duplicate Sec-WebSocket-Protocol headers (different values) [1.58ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject three Sec-WebSocket-Protocol headers [3.03ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject empty Sec-WebSocket-Protocol header [1.59ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject Sec-WebSocket-Protocol with only comma [1.53ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject Sec-WebSocket-Protocol with
... (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/mechgate.xml" test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts test/js/web/websocket/websocket-subprotocol-strict.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f4a143933)

test/js/web/websocket/websocket-subprotocol-strict.test.ts:
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols [401.89ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols with spaces [67.57ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject multiple comma-separated protocols (3 protocols) [56.86ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject duplicate Sec-WebSocket-Protocol headers (same value) [50.98ms]
(pass) WebSocket strict RFC 6455 subprotocol handling > should reject
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 687ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/13] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[2/13] cxx obj/unified/UnifiedSource-src_runtime_webview-0.cpp.o
[3/13] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-4.cpp.o
[4/13] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[5/13] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[6/13] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[7/13] gen cpp.rs (cppbind)
[7/13] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (curr
... (truncated)
diff hotspot
src/http_jsc/websocket_client.rs                   |   1 +
 .../websocket_client/WebSocketUpgradeClient.rs     | 193 ++++++++------
 src/jsc/bindings/webcore/WebSocket.cpp             |   8 +-
 src/jsc/bindings/webcore/WebSocketErrorCode.h      |   1 +
 ...websocket-permessage-deflate-edge-cases.test.ts | 152 ++++++++++-
 .../websocket/websocket-subprotocol-strict.test.ts | 285 +++++++++++++++------
 6 files changed, 474 insertions(+), 166 deletions(-)

gate history · 2 passed · 0 rejected · iteration 20

evidence per changed file
file                                                      reads  edits  tests
src/http_jsc/websocket_client.rs                              1      1     22
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs       7      8     22
src/jsc/bindings/webcore/WebSocket.cpp                        4      1     22
src/jsc/bindings/webcore/WebSocketErrorCode.h                 1      1     22
…bsocket/websocket-permessage-deflate-edge-cases.test.ts      4      3     10
…t/js/web/websocket/websocket-subprotocol-strict.test.ts      6      8     15

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The WebSocket upgrade path now rejects duplicate or unsupported extension tokens, fails handshakes when a requested subprotocol is not selected, and updates protocol-related close handling. Tests were expanded for stricter extension and subprotocol behavior.

Changes

WebSocket handshake strictness

Layer / File(s) Summary
Extension token validation
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
process_response now rejects duplicate permessage-deflate tokens and unsupported non-empty extension names in Sec-WebSocket-Extensions.
Subprotocol failure propagation
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs, src/jsc/bindings/webcore/WebSocket.cpp
A requested-but-unselected subprotocol now fails the handshake with MissingClientProtocol, and the close path marks missing/mismatch protocol failures as non-clean connection errors.
Strict handshake test coverage
test/js/web/websocket/websocket-subprotocol-strict.test.ts
Shared helpers were extracted, stricter failure assertions were added, and the suite now covers missing subprotocol, empty protocol-list success, and extension validation cases.

Suggested reviewers

  • Jarred-Sumner
🚥 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 handshake-validation changes for missing subprotocols and invalid extensions.
Description check ✅ Passed The description includes the required purpose and verification sections, with detailed problem, repro, and test info.

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:02 AM PT - Jul 14th, 2026

@robobun, your commit f4a1439 has 3 failures in Build #72921 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32825

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

bun-32825 --bun

Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated

@coderabbitai coderabbitai 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.

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-subprotocol-strict.test.ts`:
- Around line 61-65: The async disposer in the server setup returns before the
socket is fully closed, so update the [Symbol.asyncDispose] handler to wait for
server.close() to complete before resolving. Make the fix in the helper that
returns { port, [Symbol.asyncDispose] } so the websocket test cleanup is
deterministic and all open handles are released before the test exits.
🪄 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: d7914060-48c5-4640-9684-89cd008092f8

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 849ad55.

📒 Files selected for processing (3)
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/jsc/bindings/webcore/WebSocket.cpp
  • test/js/web/websocket/websocket-subprotocol-strict.test.ts

Comment thread test/js/web/websocket/websocket-subprotocol-strict.test.ts

@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.

Thanks for adding the duplicate-permessage-deflate rejection — that resolves my earlier note. I didn't find anything else, but since this tightens client handshake validation in a way that will start failing connections that previously opened (and the description itself flags a couple of related calls for a maintainer), I'll leave the sign-off to a human.

Extended reasoning...

Overview

This PR tightens RFC 6455 / RFC 7692 client-side handshake validation in three places: (1) WebSocketUpgradeClient.rs now terminates with MissingClientProtocol when the client offered subprotocols but the server's 101 didn't select one, (2) the Sec-WebSocket-Extensions loop now rejects any token other than permessage-deflate and rejects a duplicate permessage-deflate (the latter added in 849ad55 in response to my earlier comment), and (3) WebSocket.cpp's missing_client_protocol/mismatch_client_protocol arms now match the other handshake-failure arms (NotClean, isConnectionError = true) so an error event fires and wasClean is false. The test file is substantially refactored and extended to 29 cases covering both the new rejections and should-still-succeed controls.

Security risks

None apparent. The change makes the client more strict about server responses, which if anything reduces the surface for a misbehaving server to slip an unexpected extension or protocol past the handshake. No new parsing of untrusted input beyond what already existed; the new branches are bounded comparisons inside the existing comma-split loop.

Level of scrutiny

Moderate-to-high. This is protocol-level logic in the WebSocket client's handshake path (Rust with unsafe FFI termination calls, plus C++ event-semantics changes), and it is a user-visible behavior change: connections that previously fired open against non-compliant servers will now fail with error + close. The change is spec-correct and matches Node/ws, but it can break code in the wild that depended on Bun's previously-lax behavior. The PR author also explicitly defers two adjacent compatibility questions (1002 vs 1006, binaryType default) to a maintainer, which reinforces that a human should weigh in on the compat posture here.

Other factors

  • My one prior concern (silent first-wins → last-wins on duplicate permessage-deflate) was addressed exactly as suggested, with two new regression tests.
  • The new unsafe blocks follow the same Self::terminate(this, ...) + return pattern used by every sibling validation in process_response, and the new subprotocols.is_empty() read mirrors existing short-lived (*this) borrows in the same function.
  • Test coverage is thorough (failure cases, control cases, trailing-comma edge case, duplicate across one vs two headers), and the helper now asserts the full error/close/wasClean contract.
  • No CODEOWNERS coverage for these paths.

Given the behavior-change nature and the author's own flag for a maintainer call, I'm deferring rather than approving.

@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.

I didn't find any issues and the implementation looks correct, but since this tightens client-side handshake validation (rejecting connections that previously opened against non-compliant servers), it's worth a maintainer sign-off on the compat tradeoff.

Extended reasoning...

Overview

This PR tightens RFC 6455/7692 enforcement in the WebSocket client handshake across three files: WebSocketUpgradeClient.rs adds three checks in process_response (reject duplicate permessage-deflate, reject any non-permessage-deflate extension token, and fail when the client requested subprotocols but the server's 101 selects none); WebSocket.cpp flips the missing_client_protocol / mismatch_client_protocol arms to CleanStatus::NotClean + isConnectionError = true to match every sibling handshake-failure arm; and websocket-subprotocol-strict.test.ts is restructured with shared helpers and ~13 new tests covering both rejections and should-still-succeed controls. My earlier inline note about duplicate permessage-deflate handling was addressed in 849ad55, and the CodeRabbit server.close() await was addressed in 6c1d916 — both threads are resolved.

Security risks

None identified. The change parses server-supplied response headers more strictly (fail-closed rather than fail-open), which if anything reduces attack surface. The new unsafe blocks are short-lived raw-pointer reads/calls following the exact same pattern already used throughout process_response, with the same SAFETY comments.

Level of scrutiny

This is production-critical networking code on the WebSocket client connect path, and it's a user-visible behavior change: connections that previously opened (e.g. to a server that ignores the requested subprotocol, or echoes an unrecognized extension) will now fail with an error event. The change is spec-correct and matches Node/ws, but the author themselves flagged adjacent divergences (1002 vs 1006, binaryType default) as "maintainer calls" — the same compat-vs-spec judgment applies here, so a human should confirm the stricter behavior is desired.

Other factors

The implementation is small (~25 lines of production code), follows established terminate(this, ErrorCode::X); return; patterns, and is thoroughly tested (31 tests, including controls verifying the happy paths still open). I verified subprotocols is populated from both the constructor argument and any user-supplied Sec-WebSocket-Protocol header, so the new is_empty() check is sound. No CODEOWNERS cover these paths. The single CI failure on the prior commit was a Windows-aarch64 agent provisioning error, not a test failure.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: build 72921 (head f4a1439, rebased onto current main) has finished with 281 jobs passing and no WebSocket test failing on any lane. The only red test files are four unrelated main breaks that are being fixed separately:

  • test/js/node/test/parallel/test-net-connect-memleak.js on the two alpine lanes (a net.createConnection GC-collected assertion).
  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian x64-asan (a JSC exception-scope assertion in getOwnPropertyDescriptor).
  • test/js/bun/jsc-stress/fixtures/simd-baseline.test.ts on the linux x64-baseline verify lane (a SIMD-path segfault on a no-AVX CPU).
  • test/js/third_party/grpc-js/test-tonic.test.ts on darwin 14 aarch64.

This diff touches WebSocketUpgradeClient.rs, websocket_client.rs, WebSocket.cpp, WebSocketErrorCode.h, and two test files; it does not touch net, Worker, MessagePort, getOwnPropertyDescriptor, grpc, or any SIMD or baseline-CPU code path. This PR's two test files pass on every lane that ran them.

Locally against the rebased base: 40 fail / 29 pass with src/ at main, 69/69 with this branch. All four review-bot findings over the life of this PR were confirmed and fixed, every review thread is resolved, and all pre-merge checks pass. Ready for a maintainer.

@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.

No issues found and my earlier feedback was addressed — deferring only because this tightens client handshake acceptance (previously-opened connections to servers that omit the subprotocol or send an unoffered extension will now fail), which is a spec-vs-compat call on a core API that's worth a maintainer signing off on.

Extended reasoning...

Overview

Three files: WebSocketUpgradeClient.rs (process_response) adds three new termination paths — duplicate permessage-deflate, any non-permessage-deflate extension token, and a missing Sec-WebSocket-Protocol when the client requested one. WebSocket.cpp flips the missing_client_protocol / mismatch_client_protocol close arms to NotClean + isConnectionError=true so they fire error and report wasClean: false like every other handshake failure. The test file is refactored to share helpers and adds ~13 new cases (6 rejections, 3 controls, 2 missing-subprotocol, 2 empty-list controls).

Security risks

None. The change makes the client stricter about what server responses it accepts; it does not loosen any validation, expose data, or touch auth/crypto. The unsafe blocks added are the same Self::terminate(this, ...) pattern used throughout the surrounding header loop.

Level of scrutiny

Moderate. The implementation is small, follows existing patterns exactly, and is thoroughly tested (31 cases, including should-still-succeed controls). What warrants a human look is the product decision: new WebSocket(url, ["proto"]) against a server that doesn't echo a subprotocol used to open and now fails, and any server that advertises a non-permessage-deflate extension now fails the handshake. That's RFC-6455-correct and matches Node 26 / npm ws, but it can break real users talking to non-compliant servers. The author themselves flagged adjacent divergences as "left for a maintainer call," so this one belongs in the same bucket.

Other factors

My one prior inline comment (duplicate permessage-deflate becoming last-wins after the break removal) was addressed exactly as suggested in 849ad55 and has two dedicated tests. CodeRabbit's server.close() await nit was addressed in 6c1d916. All review threads are resolved. CI red on build 65393 is darwin-aarch64 artifact-download infra timeouts, not test failures. No CODEOWNERS cover these paths.

@robobun robobun changed the title websocket: fail the client handshake on a missing subprotocol or an unoffered extension websocket: fail the client handshake on a missing subprotocol or an invalid Sec-WebSocket-Extensions response Jun 28, 2026
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
@robobun
robobun force-pushed the farm/fe2a6268/ws-client-handshake-validation branch from 7dd043b to dcbcea3 Compare July 6, 2026 04:03

@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.

Both earlier review threads are addressed and I found nothing new, but this tightens client-side handshake validation in a way that will start failing connections that used to open (missing subprotocol / invalid extension params), so I'd like a maintainer to confirm the stricter behavior before it lands.

Extended reasoning...

Overview

The PR replaces the inline Sec-WebSocket-Extensions parser in WebSocketUpgradeClient.rs with a strict accept_extensions_response() + parse_window_bits() + unquote() (~100 new lines of Rust), adds a MissingClientProtocol check after the header loop, adds an InvalidExtensionsHeader error code across the Rust enum / WebSocketErrorCode.h / the didFailWithErrorCode switch in WebSocket.cpp, and flips missing_client_protocol / mismatch_client_protocol to NotClean + isConnectionError=true. Two test files gain ~250 lines covering 20 invalid and 5+ valid extension responses plus the new subprotocol/error-event semantics.

Security risks

The new code parses untrusted server bytes, but it is a straightforward split/trim/match over a bounded header value with no allocation driven by attacker-controlled sizes, and every unrecognized or malformed shape now fails closed where the old parser silently accepted. I don't see injection, auth, or memory-safety concerns; the change is strictly more defensive than what it replaces.

Level of scrutiny

This is a deliberate, spec-correct behavior change to a production networking code path: connections to servers that omit Sec-WebSocket-Protocol after a requested subprotocol, or that send an unoffered/malformed permessage-deflate parameter, previously opened and will now fail with 1002. That matches RFC 6455/7692, Chrome, Firefox, and npm ws, and the author verified Bun's own server never emits a rejected shape — but it is still the kind of compat-affecting tightening a maintainer should sign off on. The PR description itself defers two adjacent divergences ("1002 vs 1006", binaryType default) to "a maintainer call", which reinforces that expectation.

Other factors

All three prior review threads (duplicate permessage-deflate, parse_int leniency, awaiting server.close()) are resolved in the current diff and covered by new tests. The bug-hunting pass found nothing. CI redness is documented as darwin-aarch64 agent-fleet unavailability, not test failures. The change is clean and well-tested; my only reason for not approving is that it's a non-trivial protocol behavior change rather than a mechanical fix.

@robobun
robobun force-pushed the farm/fe2a6268/ws-client-handshake-validation branch from dcbcea3 to 605bee2 Compare July 6, 2026 04:30
robobun added 8 commits July 14, 2026 10:25
…noffered extension

RFC 6455 section 4.1 and the WHATWG "establish a WebSocket connection"
algorithm both require the client to fail the connection when the
server's 101:

- does not select one of the subprotocols the client requested, or
- indicates an extension the client's handshake did not offer.

Bun's client opened the connection in both cases. The
missing_client_protocol error code already existed but was never
emitted. The extension guard only ran when the client offered nothing,
and a `break` after the first permessage-deflate token skipped every
token after it.

Also make the missing/mismatch subprotocol branches fire the error
event and report wasClean: false, matching every other
handshake-failure branch.
…ponse

Per RFC 7692 section 5 a server must not list a per-message compression
extension more than once, whether in one Sec-WebSocket-Extensions value
or across several. Terminate the handshake instead of merging the
duplicate entries' parameters.
…poser

The async disposer returned before the listening socket was actually
closed, so cleanup was nondeterministic and could leak open handles
into later tests.
…ake response

The Sec-WebSocket-Extensions parser accepted any parameter list on a
permessage-deflate response: unknown parameters were ignored, a
missing, malformed, or out-of-range *_max_window_bits value was
silently dropped (server_max_window_bits=20 became the default 15),
parameters could repeat, and the no_context_takeover parameters could
carry values. RFC 7692 section 8.1 requires failing the connection in
each of those cases, and node ws, Chrome, and Firefox all do.

The response parser is now a single validator that accepts exactly one
permessage-deflate element whose parameters are the four defined ones,
each at most once, with window bits restricted to a decimal 8..15
(required in a response, unlike in an offer).

Extension negotiation failures now close with 1002 and a dedicated
reason, "Invalid Sec-WebSocket-Extensions header", instead of the
generic "Invalid response".
The value parser delegated to strings::parse_int, which also accepts a
leading `+`, embedded `_` separators, and leading zeroes, so
`server_max_window_bits=+10`, `=1_0`, and `=08` passed validation. The
RFC 7692 section 8.1.2 grammar is a decimal integer without leading
zeroes; reject those forms before parsing.
Hardening round 11 (#33072) landed the same "client requested
subprotocols, server selected none" check upstream, so this branch's
copy became a second, unreachable guard. Keep the upstream one and move
this branch's RFC citation onto it.
The check that Hardening round 11 (#33072) added asserted the reason
string "Missing client protocol", which this branch renames to "Server
sent no subprotocol" because the old wording reads as if the client were
at fault. Assert the new string, and also assert wasClean is false, the
semantics this branch fixes.
@robobun
robobun force-pushed the farm/fe2a6268/ws-client-handshake-validation branch from 605bee2 to 97a17c7 Compare July 14, 2026 10:31

@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.

No bugs found, but this is a behavior-changing protocol-validation rewrite in the WebSocket client handshake path — connections that previously opened will now fail — so it's worth a maintainer look.

Checked: accept_extensions_response against RFC 7692 §8.1 (unknown/repeated/valued/out-of-range params all reject via the _ => return false fallthrough); HeaderValueIterator skips empty list elements so the trailing-comma accept case holds; the new InvalidExtensionsHeader = 38 discriminant matches on both sides of the Rust/C++ FFI pair and the C++ switch handles it; the CleanStatus::NotClean + isConnectionError = true change to the two protocol arms matches every sibling handshake-failure arm. My two earlier inline findings (duplicate permessage-deflate merge, lenient parse_int) were both fixed and are covered by tests.

Extended reasoning...

Overview

The PR replaces the lenient inline Sec-WebSocket-Extensions parser in WebSocketUpgradeClient.rs::process_response with a strict accept_extensions_response() helper (~70 lines) plus parse_window_bits/unquote helpers, adds a new InvalidExtensionsHeader error code across the mirrored Rust/C++ ErrorCode enums, wires it into WebSocket::didFailWithErrorCode, and flips the missing_client_protocol/mismatch_client_protocol arms from CleanStatus::Clean to NotClean + isConnectionError = true. Two test files gain ~35 new cases (20 invalid extension responses, 5 valid controls, missing-subprotocol cases, and a strengthened expectConnectionFailure helper that now asserts exactly one error event and wasClean: false).

Security risks

None identified. The change tightens validation of server-controlled input (fails closed rather than silently accepting malformed extension parameters). No new allocation driven by untrusted lengths; parsing is bounded by the header value already parsed by picohttp.

Level of scrutiny

Medium-high. This is production WebSocket client code and a deliberate behavior change: servers whose 101 responses were previously tolerated (unoffered extension, out-of-range server_max_window_bits, valued no_context_takeover, etc.) will now fail the handshake. The PR description argues this is spec-mandated and matches Chrome/Firefox/npm ws, and confirms Bun's own server never emits a rejected shape — but the compatibility surface is real and the author explicitly flags two adjacent divergences as "left for a maintainer call". That, plus the ~100 lines of new protocol parsing, puts it above the auto-approve bar.

Other factors

The bug-hunting system found nothing this run. My two prior inline findings (silent first-wins→last-wins on duplicate permessage-deflate; strings::parse_int accepting +/_/leading zeros) were both addressed with tests. All review threads are resolved. Test coverage is thorough — negative and positive controls, quoted values, cross-header duplicates, and a post-negotiation inflate round-trip. The rebase-conflict resolution (discriminant 37→38, dedup of the subprotocol check that landed independently in #33072, reason-string rename) is correctly described and reflected in the diff. The renamed "Server sent no subprotocol" reason string is a user-visible change but the old string only became reachable after #33072 landed, so exposure is minimal.

`server.listen(0)` without a hostname plus `ws://localhost:` can fail
on a dual-stack agent whose `localhost` resolution is broken at the
time, surfacing as a `{ code: 1006, reason: "Failed to connect" }`
after the upgrade timeout. Bind to and connect over 127.0.0.1 like the
neighboring WebSocket test files do.

@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.

No issues found, but this rewrites the Sec-WebSocket-Extensions response parser and changes the error/wasClean semantics for two handshake-failure codes, so it's worth a human look before merging.

Checked: the new accept_extensions_response covers unknown/repeated/valued-where-forbidden/out-of-range parameters with a rejecting fall-through; the ErrorCode enum discriminant 38 matches on both the Rust and C++ sides after the rebase; HeaderValueIterator skips empty list elements so a bare or trailing comma doesn't spuriously reject; and Bun.serve's own WebSocketExtensions.h output stays within what the new validator accepts. Both of my earlier inline findings (duplicate-element handling, parse_int leniency on +/_/leading zeroes) are fixed and now have tests.

Extended reasoning...

Overview

This PR tightens RFC 6455 §4.1 / RFC 7692 §8.1 enforcement in the WebSocket client handshake. It replaces the inline Sec-WebSocket-Extensions parser in WebSocketUpgradeClient.rs with a strict accept_extensions_response() that rejects unoffered extensions, duplicate permessage-deflate elements, and unknown/repeated/malformed parameters. It adds a new InvalidExtensionsHeader error code (Rust enum + WebSocketErrorCode.h + WebSocket.cpp switch arm), and flips missing_client_protocol/mismatch_client_protocol from CleanStatus::Clean to NotClean with isConnectionError = true so they fire an error event like every other handshake failure. ~470 lines changed across 6 files, roughly two-thirds of it tests.

Security risks

None introduced. The change validates untrusted server input more strictly than before; there is no new parsing of attacker-controlled data that could overflow or allocate unboundedly (the parser operates on borrowed slices of an already-bounded picohttp header value). The 1*DIGIT guard added for window-bits values closes a minor spec-leniency gap rather than opening one.

Level of scrutiny

Medium-high. This is production network protocol code on a hot path (process_response runs for every WebSocket client connection), and it's a behavior-tightening change: servers whose responses were previously accepted (out-of-range window bits silently clamped, unknown extensions silently ignored) will now fail the handshake. The PR description argues convincingly that Bun's own server, npm ws, and browsers already conform, and the adjacent test suites are unchanged — but a maintainer should confirm the compat trade-off is acceptable.

Other factors

  • Test coverage is thorough: 20 invalid + 5 valid extension responses, duplicate-element in one header vs across two headers, a real inflate round-trip after negotiation, and the shared expectConnectionFailure helper now asserts error-event count and wasClean: false for all 11 pre-existing subprotocol-mismatch tests.
  • Both of my earlier inline findings on this PR were confirmed, fixed with dedicated commits, and covered by new test cases; all threads are resolved.
  • The FFI enum pair is an easy place to silently break — verified InvalidExtensionsHeader = 38 on the Rust side matches invalid_extensions_header = 38 in the C++ header after the rebase collision with #33395's unexpected_rsv1 = 37.
  • The reason-string change ("Missing client protocol" → "Server sent no subprotocol") is technically a user-visible change, but the PR notes the old string was unreachable until #33072 landed, so nothing depends on it.
  • CI (build 72895) is green on 278+ jobs; the one red test is an unrelated musl net.createConnection GC assertion.

Not approving because the parser rewrite, event-semantics change, and validation tightening are substantive enough that a maintainer should sign off — this isn't a mechanical change.

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