ws: validate close(code, reason) like npm ws

Closed
RO
1mo ago
+149-3

Problem

  • In the built-in ws shim, a server connection's ws.close(code, reason) reached ServerWebSocket.close() unchecked. close(999), close(1015), close(-1) and close(1000, "é".repeat(70)) each put a Close frame on the wire. Peers report 1002, 1005 or 1007, and Chromium fails the connection for 1015 and for a reason cut inside a character.
  • npm ws throws in Sender.prototype.close and sends nothing: a TypeError outside 1000-1003, 1007-1014 and 3000-4999, a RangeError for a reason over 123 bytes. The shim's client class threw a DOMException instead, and sent a Uint8Array reason as "104,105".

Fix

  • closeReason() in src/js/thirdparty/ws.js ports those checks with the same errors. Both socket classes run it on an OPEN socket before any state change, so a caught error leaves the socket usable.
  • Like npm ws, data is ignored without a code or without a length, a typed array reason is sent as its bytes, and a socket that is not OPEN checks nothing.
  • Not changed: ServerWebSocket.close() itself (#41665 covers its UTF-8 cut, a range check there changes Bun.serve). Found by a parity audit, not a user report.
  • Verified: test/js/first_party/ws/ws.test.ts, block "close() arguments" (all four fail on 1.4.3), plus the other ws suites.

Background

  • "ws" always resolves to the shim. Its server socket sits on a Bun.serve websocket, where uWS end() casts the code to uint16_t and cuts the reason at byte 123. Its client socket wraps the global WebSocket, which checks the same codes since #32820.
  • RFC 6455 §7.4: 1004, 1005, 1006 and 1015 never go in a Close frame. §5.5: its payload is at most 125 bytes, 2 of them the code.
Notes

Repro, bun shimclose.mjs with no node_modules against node shimclose.mjs with ws 8.18.3:

import { WebSocketServer, WebSocket } from "ws";
const CASES = [[999],[1004],[1005],[1006],[1015],[1016],[2999],[5000],[65535],[100000],[-1],[NaN],["1000"],
  [1000,"q".repeat(200)],[1000,"é".repeat(70)],[undefined,"q".repeat(200)],[1000,Buffer.from("buf")],
  [1000,new Uint8Array([104,105])],[1000,42],[4000,"ok"]];
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); let i = 0;
wss.on("listening", next);
function next() { if (i >= CASES.length) process.exit(0); const a = CASES[i++];
  const c = new WebSocket("ws://127.0.0.1:" + wss.address().port);
  c.on("close", (code, r) => { console.log("   peer saw", code, JSON.stringify(String(r).slice(0, 12)), Buffer.byteLength(String(r)), "B"); next(); });
  c.on("error", () => {}); }
wss.on("connection", ws => { const a = CASES[i - 1];
  try { ws.close(...a); console.log(`close(${String(a[0])}) -> no throw`); }
  catch (e) { console.log(`close(${String(a[0])}) -> ${e.constructor.name}: ${e.message}`); ws.terminate(); } });

bun 1.4.3: every call returns. The peer sees 1002 for 999, 1004, 1015, 1016, 2999, 5000, 65535, 100000 and -1, 1005 for 1005, 1006 and NaN, a reason cut to 123 bytes for the 200-byte case, 1007 for "é"×70, "104,105" for the Uint8Array and "42" for 42. close("1000") threw close requires a numeric code or undefined and left the socket in CLOSING with the connection open.

node 26 + ws 8.18.3: TypeError for the 13 code cases, RangeError for the two long reasons. close(undefined, long) sends an empty Close frame, the Uint8Array arrives as hi, 42 is ignored, close(4000, "ok") arrives as given. With this change the shim matches that table, except that a no-code close() still sends code 1000 where npm ws sends an empty Close frame (the peer sees 1005). That difference existed before and is not touched here.

npm ws sets _readyState = CLOSING before Sender.close throws, which leaves a socket whose close() no longer does anything. The shim checks first, so the state is unchanged after a throw. The first test pins that (readyState 1, then close(4000, "ok") reaches the peer).

Client class before this change: an OPEN socket threw InvalidAccessError or SyntaxError (DOMException) from the native close(), close("1000") was accepted as 1000, and a CONNECTING, CLOSING or CLOSED socket also threw for a bad code. npm ws only checks on an OPEN socket: CONNECTING aborts the handshake, CLOSING and CLOSED return. The client class now does the same by calling the native close() without arguments in those states. The native check from #32820 stays as it is underneath.

A typed array reason is decoded to UTF-8 before the length check, so the 123-byte limit applies to the bytes that go on the wire. For a view that holds invalid UTF-8 this differs from npm ws, which sends the raw bytes.

#32820 added the same code set to the native client close() and named ServerWebSocket#close() as not addressed. This change covers the ws side of that. #41665 is the native-side complement for the reason. Overlap with open PRs: #35031 (WHATWG range for the global WebSocket.close()) carries an older version of the code check for both shim classes, without the reason check; its ws.js hunks are superseded by this. #39802 rewrites the server socket close lifecycle and touches the same close() method; closeReason() has to run before its pending-close bookkeeping, otherwise the conflict is mechanical.

Suites run on the debug build: test/js/first_party/ws/ws.test.ts (69 pass), ws-upgrade-events.test.ts, ws-proxy.test.ts, test/js/node/http/node-http-with-ws.test.ts, test/js/web/websocket/websocket-client.test.ts, websocket-close-connecting.test.ts, websocket-buffered-amount.test.ts, test/regression/issue/{32734,3613,012040,26358,29684,03844}.

Self-reviewed: the review asked for the client class to go through the same helper and for the PR text to name the related PRs; both done.


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

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

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:38937/
(pass) WebSocket > url [1678.82ms]
Listening: ws://127.0.0.1:34335/
Received upgrade: {
  host: "127.0.0.1:34335",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "Dg9qPYNHFtpLARTy6EDoaA==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [1896.12ms]
Listening: ws://127.0.0.1:40347/
(pass) WebSocket > binaryType > (default) [1415.43ms]
Listening: ws://127.0.0.1:33541/
(pass) WebSocket > binaryType > (invalid) [1540.05ms]
Listening: ws://127.0.0.1:42621/
Received upgrade: {
  host: "127.0.0.1:42621",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "3xa/by3IhKDQ2WDMiY7jLQ==",
}
Received connection: 127.0.0.1
Received message: <Buffer 
... (truncated)

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

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:38077/
(pass) WebSocket > url [37.20ms]
Listening: ws://127.0.0.1:44661/
Received upgrade: {
  host: "127.0.0.1:44661",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "2L84r4Bi8fQn54EQHao5PQ==",
}
Received connection: 127.0.0.1
Received close: 1000 
(pass) WebSocket > readyState [30.61ms]
Listening: ws://127.0.0.1:42289/
(pass) WebSocket > binaryType > (default) [23.39ms]
Listening: ws://127.0.0.1:40577/
(pass) WebSocket > binaryType > (invalid) [24.92ms]
Listening: ws://127.0.0.1:38219/
Received upgrade: {
  host: "127.0.0.1:38219",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "8pG0Ctyj68F6IrF473sbGw==",
}
Received connection: 127.0.0.1
Received message: <Buffer 00>
Received ping: <Buffer >
Received pong: <Buffer >
Received pong: <Buffer >
(pass) WebSocket > binaryType > nodebuffer [35.46ms]
Listening: 
... (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/first_party/ws/ws.test.ts
bun test v1.4.3 (f42e98025)

test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:37813/
(pass) WebSocket > url [1416.01ms]
Listening: ws://127.0.0.1:44623/
Received upgrade: {
  host: "127.0.0.1:44623",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "AWjmqcQ1HlFLqkl1Nvlkng==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [1787.18ms]
Listening: ws://127.0.0.1:41347/
(pass) WebSocket > binaryType > (default) [1303.14ms]
Listening: ws://127.0.0.1:46095/
(pass) WebSocket > binaryType > (invalid) [1316.91ms]
Listening: ws://127.0.0.1:41333/
Received upgrade: {
  host: "127.0.0.1:41333",
  connection: "Upgrade",
  upgrade: "websocket",
  "sec-websocket-version": "13",
  "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
  "sec-websocket-key": "SzDNVM/MdhYl3omCD8mH9A==",
}
Received connection: 127.0.0.1
Received message: <Buffer 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 753ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (8710ms)
Bundle modules (90ms)
Postprocesss modules (159ms)
Bundle Functions (410ms)
Generate Code (38ms)

[9.41s] Bundled "src/js" for production
  2595 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/5] cargo bun_runtime → libbun_runtime.a
   Compiling bun_install v0.0.0 (/workspace/bun/src/install)
   Compiling bun_jsc v0.0.0 (/workspace/bun/src/jsc)
   Compiling bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
   Compiling bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
   Compiling bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
   Compiling bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
   Compiling bun_sourcemap_jsc v0.0.0 (/workspace/bun/src/sourcemap_jsc)
   Compiling bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
   Compiling bun_install_jsc v0.0.0 (/workspa
... (truncated)
diff hotspot
src/js/thirdparty/ws.js           |  35 +++++++++++-
 test/js/first_party/ws/ws.test.ts | 117 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 149 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                               reads  edits  tests
src/js/thirdparty/ws.js                5      5     15
test/js/first_party/ws/ws.test.ts      4      4     15

root cause · written by the author bot

Bun's built-in ws shim forwarded close(code, reason) straight to the underlying socket without running the argument checks that npm ws performs in Sender.prototype.close, so codes an endpoint may not send (999, 1004-1006, 1015, 1016, 2999, 5000 and out-of-range values) and reasons longer than 123 bytes were written to the wire, where peers and real browsers rejected them as protocol errors or truncated the reason mid-codepoint. The fix adds upstream-matching validation helpers that reject invalid status codes with a TypeError and over-long reasons with a RangeError, and routes bot…