WebSocket: reject ping()/pong() payloads over 125 bytes (client, Bun.serve, ws shim)
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()insrc/jsc/bindings/webcore/WebSocket.cpppass the payload straight tosendWebSocketData(Opcode::Ping|Pong).ServerWebSocket::send_pinginsrc/runtime/server/ServerWebSocket.rspasses the payload straight to uWSsend();protocol::formatMessagepicks 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 atry/catchthat routes any error tocb/'error', which would have diverged from npm ws even once the throw was added.
Fix
- Client
WebSocket: add amaxControlFramePayloadSize = 125check to everyping()/pong()overload (String, ArrayBuffer, ArrayBufferView, Blob) that returnsException { 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.serveServerWebSocket: add the same check tosend_ping(backs bothws.ping()andws.pong()), throwing aRangeErrorwith the identical message before calling uWS.require("ws")shim: drop thetry/catcharoundthis.#ws.ping()/pong(). npm ws has no catch aroundthis._sender.ping(...), so the RangeError (and any other validation error) propagates synchronously to the caller, never reachingcbor 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)
[1m[92m Compiling[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
[1m[92m Compiling[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
[1m[92m Compiling[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
[1m[92m Compiling[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
[1m[92m Compiling[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
[1m[92m
... (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