WebSocket: reject ping()/pong() payloads over 125 bytes (client, Bun.serve, ws shim)

Merged
RO
4mo ago
+443-52

Reproduction

// a raw TCP server that speaks the handshake and logs incoming frames
ws.ping("b".repeat(126));
ws.ping("c".repeat(300));
ws.pong(new Uint8Array(400));
// => [{"opcode":9,"payloadLen":126,"extendedLen":true},
//     {"opcode":9,"payloadLen":300,"extendedLen":true},
//     {"opcode":10,"payloadLen":400,"extendedLen":true}]

Bun emits a single control frame with an extended 16-bit length header. RFC 6455 §5.5 says "All control frames MUST have a payload length of 125 bytes or less", so every conformant peer closes the connection as a protocol error. Bun is self-inconsistent here: its own client receive path enforces MAX_CONTROL_PAYLOAD = 125 and closes with 1002 "invalid control frame", and Bun.serve drops the connection with 1006 without ever invoking the ping/pong handlers.

Cause

None of the ping()/pong() send paths check payload length:

  • WebSocket::ping() / WebSocket::pong() in src/jsc/bindings/webcore/WebSocket.cpp pass the payload straight to sendWebSocketData(Opcode::Ping|Pong).
  • ServerWebSocket::send_ping in src/runtime/server/ServerWebSocket.rs passes the payload straight to uWS send(); protocol::formatMessage picks the frame header purely by length and writes the extended-length form for anything over 125 bytes.
  • The require("ws") shim wraps both of the above in a try/catch that routes any error to cb/'error', which would have diverged from npm ws even once the throw was added.

Fix

  • Client WebSocket: add a maxControlFramePayloadSize = 125 check to every ping()/pong() overload (String, ArrayBuffer, ArrayBufferView, Blob) that returns Exception { RangeError, "The data size must not be greater than 125 bytes. Received N bytes." } before sending. The String overloads now encode to UTF-8 once and reuse that buffer for both the length check and the send, so the limit applies to the on-wire byte length.
  • Bun.serve ServerWebSocket: add the same check to send_ping (backs both ws.ping() and ws.pong()), throwing a RangeError with the identical message before calling uWS.
  • require("ws") shim: drop the try/catch around this.#ws.ping()/pong(). npm ws has no catch around this._sender.ping(...), so the RangeError (and any other validation error) propagates synchronously to the caller, never reaching cb or an 'error' event. The catch was effectively dead before this PR anyway since the CONNECTING state is pre-checked.

Verification

$ bun bd test test/js/web/websocket/websocket-client.test.ts -t "payload size limit"
(pass) ... > throws RangeError for payloads > 125 bytes and never puts an extended-length control frame on the wire
(pass) ... > a 125-byte ping round-trips through Bun.serve without a protocol error
(pass) ... > require('ws') client throws RangeError synchronously for oversized ping/pong
(pass) ... > Bun.serve ServerWebSocket.ping()/pong() throws RangeError for payloads > 125 bytes
 4 pass  0 fail

The wire-level test connects to a raw TCP server that parses incoming frames and asserts that only the four 125-byte frames (plus a trailing empty ping) are ever sent; every oversized call throws and leaves the socket open. The full websocket-client.test.ts (33 tests), websocket-server.test.ts (107 tests), websocket-blob.test.ts, and the updated 126-byte case in ws.test.ts pass.


[review] gate passed · iteration 2 · 5 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/first_party/ws/ws.test.ts test/js/web/websocket/websocket-client.test.ts
bun test v1.4.0 (6f411aaab)

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:37789/
(pass) WebSocket > url [1903.87ms]
Listening: ws://127.0.0.1:39121/
Received upgrade: {
  host: "127.0.0.1:39121",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "xC93BzukSNOUn0A1nZhRxg==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [2202.10ms]
Listening: ws://127.0.0.1:46811/
(pass) WebSocket > binaryType > (default) [1927.10ms]
Listening: ws://127.0.0.1:41121/
(pass) WebSocket > binaryType > (invalid) [1880.52ms]
Listening: ws://127.0.0.1:39693/
Received upgrade: {
  host: "127.0.0.1:39693",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "60iuzG0zTp2QR5Zso+HZDg==",
}
Received 
... (truncated)

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

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:45843/
(pass) WebSocket > url [38.71ms]
Listening: ws://127.0.0.1:34325/
Received upgrade: {
  host: "127.0.0.1:34325",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "WfQrs37aRSOI3RjnY0pWug==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [40.51ms]
Listening: ws://127.0.0.1:38117/
(pass) WebSocket > binaryType > (default) [37.12ms]
Listening: ws://127.0.0.1:45171/
(pass) WebSocket > binaryType > (invalid) [36.74ms]
Listening: ws://127.0.0.1:43211/
Received upgrade: {
  host: "127.0.0.1:43211",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "Q+5Va2H6TjG5XDS0smYAHw==",
}
Received connection: 127.0.0.1
Received message: <Buffer 00>
Received ping: <Buffer >
(pass) WebSocket > binaryType > nodebuffer [40.28ms]
Listening: ws://127.0.0.1:35875/
Received upgrade: {
  host: "127.0.0.1:35875",
  c
... (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/first_party/ws/ws.test.ts test/js/web/websocket/websocket-client.test.ts
bun test v1.4.0 (6f411aaab)

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:45807/
(pass) WebSocket > url [1974.39ms]
Listening: ws://127.0.0.1:35023/
Received upgrade: {
  host: "127.0.0.1:35023",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "PIPYFX48Q46ZtL6cLvtZLQ==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [2187.19ms]
Listening: ws://127.0.0.1:39153/
(pass) WebSocket > binaryType > (default) [1890.36ms]
Listening: ws://127.0.0.1:43671/
(pass) WebSocket > binaryType > (invalid) [1881.96ms]
Listening: ws://127.0.0.1:32833/
Received upgrade: {
  host: "127.0.0.1:32833",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "3arbGXM3RBuMVUUxNs0V7A==",
}
Received 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 665ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen cpp.rs (cppbind)
[2/23] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/23] gen JS modules (bundle-modules)
Preprocess modules (7067ms)
Bundle modules (50ms)
Postprocesss modules (27ms)
Bundle Functions (711ms)
Generate Code (86ms)

[7.96s] Bundled "src/js" for production
  2040 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
   Compiling bun_safety v0.0.0 (/workspace/bun/src/safety)
 
... (truncated)
diff hotspot
src/js/thirdparty/ws.js                        |  75 +++---
 src/jsc/bindings/webcore/WebSocket.cpp         |  37 ++-
 src/runtime/server/ServerWebSocket.rs          |  19 +-
 test/js/first_party/ws/ws.test.ts              |  14 +-
 test/js/web/websocket/websocket-client.test.ts | 350 +++++++++++++++++++++++++
 5 files changed, 443 insertions(+), 52 deletions(-)

gate history · 4 passed · 1 rejected · iteration 2

evidence per changed file
file                                            reads  edits  tests
src/js/thirdparty/ws.js                             7      8      0
src/jsc/bindings/webcore/WebSocket.cpp              4      9      0
src/runtime/server/ServerWebSocket.rs               2      3      0
test/js/first_party/ws/ws.test.ts                   1      3      0
test/js/web/websocket/websocket-client.test.ts      8     23      0