Repository: nodejs/undici (JavaScript)

Issue:
Invalid WebSocket frame: invalid status code 0
### Version

v20.11.0

### Platform

`Linux ndndev 6.1.0-17-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.69-1 (2023-12-30) x86_64 x86_64 x86_64 GNU/Linux`

### Subsystem

WebSocket

### What steps will reproduce the bug?

**main.mjs**

```js
import { WebSocketServer } from "ws";
import { once } from "node:events";

const server = new WebSocketServer({
  host: "127.0.0.1",
  port: 6680,
});
await once(server, "listening");
console.log("Server is listening")

server.on("connection", (sock, request) => {
  console.log("Server has received client connection, port", request.socket.remotePort);
  setTimeout(() => {
    console.log("Server is closing client connection");
    sock.close();
  }, 3000);
});

const client = new WebSocket(`ws://127.0.0.1:6680`);
console.log("Client is created");
await once(client, "open");
console.log("Client is open");

await once(client, "close");
console.log("Client is closed");

server.close();
console.log("Server is closing");
await once(server, "close");
console.log("Server is closed");
```

**package.json**

```json
{
  "private": true,
  "packageManager": "pnpm@8.15.1+sha256.245fe901f8e7fa8782d7f17d32b6a83995e2ae03984cb5b62b8949bfdc27c7b5",
  "dependencies": {
    "ws": "^8.16.0"
  }
}
```

### How often does it reproduce? Is there a required condition?

This bug always happens when the above snippet is invoked with `--experimental-websocket` flag.

```bash
node --experimental-websocket main.mjs
```

### What is the expected behavior? Why is that the expected behavior?

The expected behavior may be obtained by importing `WebSocket` from **ws** package.
The console log is:

```text
Server is listening
Client is created
Server has received client connection, port 49956
Client is open
Server is closing client connection
Client is closed
Server is closing
Server is closed
```

[ws-websocket.pcapng.zip](https://github.com/nodejs/node/files/14112958/ws-websocket.pcapng.zip) is the packet capture of this execution.
When the WebSocketServer closes the client connection in Frame 8, the WebSocket client responds with a Close frame in Frame 10.
Close frame from **ws** WebSocket client is encoded as:

```text
WebSocket
    1... .... = Fin: True
    .000 .... = Reserved: 0x0
    .... 1000 = Opcode: Connection Close (8)
    1... .... = Mask: True
    .000 0000 = Payload length: 0
    Masking-Key: 9c72f352
```

Notably, there isn't a payload field in this packet.

### What do you see instead?

The console log is:

```text
Server is listening
Client is created
(node:289454) [UNDICI-WS] Warning: WebSockets are experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
Server has received client connection, port 34810
Client is open
Server is closing client connection
node:events:496
      throw er; // Unhandled 'error' event
      ^

RangeError: Invalid WebSocket frame: invalid status code 0
    at Receiver.controlMessage (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/receiver.js:633:30)
    at Receiver.getData (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/receiver.js:481:12)
    at Receiver.startLoop (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/receiver.js:171:16)
    at Receiver._write (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/receiver.js:98:10)
    at writeOrBuffer (node:internal/streams/writable:564:12)
    at _write (node:internal/streams/writable:493:10)
    at Writable.write (node:internal/streams/writable:502:10)
    at Socket.socketOnData (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/websocket.js:1303:35)
    at Socket.emit (node:events:518:28)
    at addChunk (node:internal/streams/readable:559:12)
Emitted 'error' event on WebSocket instance at:
    at Receiver.receiverOnError (/home/ubuntu/x/node_modules/.pnpm/ws@8.16.0/node_modules/ws/lib/websocket.js:1189:13)
    at Receiver.emit (node:events:518:28)
    at emitErrorNT (node:internal/streams/destroy:169:8)
    at emitErrorCloseNT (node:internal/streams/destroy:128:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
  code: 'WS_ERR_INVALID_CLOSE_CODE',
  [Symbol(status-code)]: 1002
}

Node.js v20.11.0
```

While the error appears to come from **ws**, it is caused by incorrect packet encoding by Node.js native WebSocket implementation.
[node-websocket.pcapng.zip](https://github.com/nodejs/node/files/14112929/node-websocket.pcapng.zip) is the packet capture of this execution.
When the WebSocketServer closes the client connection in Frame 8, the WebSocket client responds with a Close frame in Frame 10.
Close frame from Node.js native WebSocket is encoded as:

```text
WebSocket
    1... .... = Fin: True
    .000 .... = Reserved: 0x0
    .... 1000 = Opcode: Connection Close (8)
    1... .... = Mask: True
    .000 0010 = Payload length: 2
    Masking-Key: 8d28378b
    Masked payload
    Payload
        Close
            Status code: Unknown (0)
```

Notably, there's a payload containing a Status code that is zero.

### Additional information

According to [RFC 6455 section 5.5.1](https://datatracker.ietf.org/doc/html/rfc6455#section-5.5.1):

> The Close frame MAY contain a body that indicates a reason for closing.
> If there is a body, the first two bytes of the body MUST be a 2-byte unsigned integer (in network byte order) representing a status code with value /code/ defined in Section 7.4.

According to [RFC 6455 section 7.4.2](https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.2):

> Status codes in the range 0-999 are not used.

Node.js native WebSocket client has violated the protocol by sending an unassigned value as the status code.


Discussion:

Patch:
diff --git a/lib/websocket/receiver.js b/lib/websocket/receiver.js
index c75939c29b7..28ff8b25f5c 100644
--- a/lib/websocket/receiver.js
+++ b/lib/websocket/receiver.js
@@ -107,8 +107,11 @@ class ByteParser extends Writable {
             // Close frame, the endpoint MUST send a Close frame in response.  (When
             // sending a Close frame in response, the endpoint typically echos the
             // status code it received.)
-            const body = Buffer.allocUnsafe(2)
-            body.writeUInt16BE(this.#info.closeInfo.code, 0)
+            let body = emptyBuffer
+            if (this.#info.closeInfo.code) {
+              body = Buffer.allocUnsafe(2)
+              body.writeUInt16BE(this.#info.closeInfo.code, 0)
+            }
             const closeFrame = new WebsocketFrameSend(body)
 
             this.ws[kResponse].socket.write(


Test patch:
diff --git a/test/websocket/issue-2679.js b/test/websocket/issue-2679.js
new file mode 100644
index 00000000000..85efe10a4f2
--- /dev/null
+++ b/test/websocket/issue-2679.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const { test } = require('node:test')
+const assert = require('node:assert')
+const { once } = require('node:events')
+const { WebSocketServer } = require('ws')
+const { WebSocket } = require('../..')
+
+test('Close without receiving code does not send an invalid payload', async () => {
+  const server = new WebSocketServer({ port: 0 })
+
+  await once(server, 'listening')
+
+  server.on('connection', (sock, request) => {
+    setTimeout(() => {
+      sock.close()
+    }, 3000)
+  })
+
+  server.on('error', (err) => assert.ifError(err))
+
+  const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`)
+  await once(client, 'open')
+
+  await once(client, 'close')
+
+  server.close()
+  await once(server, 'close')
+})