Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix websocket receiving an invalid utf-8 in close frame #3206

Merged
merged 5 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions lib/web/websocket/connection.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
'use strict'

const { uid, states, sentCloseFrameState } = require('./constants')
const { uid, states, sentCloseFrameState, emptyBuffer, opcodes } = require('./constants')
const {
kReadyState,
kSentClose,
kByteParser,
kReceivedClose
kReceivedClose,
kResponse
} = require('./symbols')
const { fireEvent, failWebsocketConnection } = require('./util')
const { fireEvent, failWebsocketConnection, isClosing, isClosed, isEstablished } = require('./util')
const { channels } = require('../../core/diagnostics')
const { CloseEvent } = require('./events')
const { makeRequest } = require('../fetch/request')
const { fetching } = require('../fetch/index')
const { Headers } = require('../fetch/headers')
const { getDecodeSplit } = require('../fetch/util')
const { kHeadersList } = require('../../core/symbols')
const { WebsocketFrameSend } = require('./frame')

/** @type {import('crypto')} */
let crypto
Expand Down Expand Up @@ -211,6 +213,72 @@ function establishWebSocketConnection (url, protocols, ws, onEstablish, options)
return controller
}

function closeWebSocketConnection (ws, code, reason, reasonByteLength) {
if (isClosing(ws) || isClosed(ws)) {
// If this's ready state is CLOSING (2) or CLOSED (3)
// Do nothing.
} else if (!isEstablished(ws)) {
// If the WebSocket connection is not yet established
// Fail the WebSocket connection and set this's ready state
// to CLOSING (2).
failWebsocketConnection(ws, 'Connection was closed before it was established.')
ws[kReadyState] = states.CLOSING
} else if (ws[kSentClose] === sentCloseFrameState.NOT_SENT) {
// If the WebSocket closing handshake has not yet been started
// Start the WebSocket closing handshake and set this's ready
// state to CLOSING (2).
// - If neither code nor reason is present, the WebSocket Close
// message must not have a body.
// - If code is present, then the status code to use in the
// WebSocket Close message must be the integer given by code.
// - If reason is also present, then reasonBytes must be
// provided in the Close message after the status code.

ws[kSentClose] = sentCloseFrameState.PROCESSING

const frame = new WebsocketFrameSend()

// If neither code nor reason is present, the WebSocket Close
// message must not have a body.

// If code is present, then the status code to use in the
// WebSocket Close message must be the integer given by code.
if (code !== undefined && reason === undefined) {
frame.frameData = Buffer.allocUnsafe(2)
frame.frameData.writeUInt16BE(code, 0)
} else if (code !== undefined && reason !== undefined) {
// If reason is also present, then reasonBytes must be
// provided in the Close message after the status code.
frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength)
frame.frameData.writeUInt16BE(code, 0)
// the body MAY contain UTF-8-encoded data with value /reason/
frame.frameData.write(reason, 2, 'utf-8')
} else {
frame.frameData = emptyBuffer
}

/** @type {import('stream').Duplex} */
const socket = ws[kResponse].socket

socket.write(frame.createFrame(opcodes.CLOSE), (err) => {
if (!err) {
ws[kSentClose] = sentCloseFrameState.SENT
}
})

ws[kSentClose] = sentCloseFrameState.PROCESSING

// Upon either sending or receiving a Close control frame, it is said
// that _The WebSocket Closing Handshake is Started_ and that the
// WebSocket connection is in the CLOSING state.
ws[kReadyState] = states.CLOSING
} else {
// Otherwise
// Set this's ready state to CLOSING (2).
ws[kReadyState] = states.CLOSING
}
}

/**
* @param {Buffer} chunk
*/
Expand All @@ -237,10 +305,10 @@ function onSocketClose () {

const result = ws[kByteParser].closingInfo

if (result) {
if (result && !result.error) {
code = result.code ?? 1005
reason = result.reason
} else if (ws[kSentClose] !== sentCloseFrameState.SENT) {
} else if (!ws[kReceivedClose]) {
// If _The WebSocket
// Connection is Closed_ and no Close control frame was received by the
// endpoint (such as could occur if the underlying transport connection
Expand Down Expand Up @@ -293,5 +361,6 @@ function onSocketError (error) {
}

module.exports = {
establishWebSocketConnection
establishWebSocketConnection,
closeWebSocketConnection
}
20 changes: 17 additions & 3 deletions lib/web/websocket/receiver.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { kReadyState, kSentClose, kResponse, kReceivedClose } = require('./symbol
const { channels } = require('../../core/diagnostics')
const { isValidStatusCode, failWebsocketConnection, websocketMessageReceived, utf8Decode } = require('./util')
const { WebsocketFrameSend } = require('./frame')
const { CloseEvent } = require('./events')

// This code was influenced by ws released under the MIT license.
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Expand Down Expand Up @@ -55,6 +56,12 @@ class ByteParser extends Writable {

this.#info.fin = (buffer[0] & 0x80) !== 0
this.#info.opcode = buffer[0] & 0x0F
this.#info.masked = (buffer[1] & 0x80) === 0x80

if (this.#info.masked) {
failWebsocketConnection(this.ws, 'Frame cannot be masked')
return callback()
}

// If we receive a fragmented message, we use the type of the first
// frame to parse the full message as binary/text, when it's terminated
Expand Down Expand Up @@ -102,6 +109,13 @@ class ByteParser extends Writable {

this.#info.closeInfo = this.parseCloseBody(body)

if (this.#info.closeInfo.error) {
const { code, reason } = this.#info.closeInfo

callback(new CloseEvent('close', { wasClean: false, reason, code }))
return
}

if (this.ws[kSentClose] !== sentCloseFrameState.SENT) {
// If an endpoint receives a Close frame and did not previously send a
// Close frame, the endpoint MUST send a Close frame in response. (When
Expand Down Expand Up @@ -310,16 +324,16 @@ class ByteParser extends Writable {
}

if (code !== undefined && !isValidStatusCode(code)) {
return null
return { code: 1002, reason: 'Invalid status code', error: true }
}

try {
reason = utf8Decode(reason)
} catch {
return null
return { code: 1007, reason: 'Invalid UTF-8', error: true }
}

return { code, reason }
return { code, reason, error: false }
}

get closingInfo () {
Expand Down
3 changes: 2 additions & 1 deletion lib/web/websocket/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ function failWebsocketConnection (ws, reason) {
if (reason) {
// TODO: process.nextTick
fireEvent('error', ws, (type, init) => new ErrorEvent(type, init), {
error: new Error(reason)
error: new Error(reason),
message: reason
})
}
}
Expand Down
84 changes: 16 additions & 68 deletions lib/web/websocket/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const { webidl } = require('../fetch/webidl')
const { URLSerializer } = require('../fetch/data-url')
const { getGlobalOrigin } = require('../fetch/global')
const { staticPropertyDescriptors, states, sentCloseFrameState, opcodes, emptyBuffer } = require('./constants')
const { staticPropertyDescriptors, states, sentCloseFrameState, opcodes } = require('./constants')
const {
kWebSocketURL,
kReadyState,
Expand All @@ -16,18 +16,17 @@ const {
const {
isConnecting,
isEstablished,
isClosed,
isClosing,
isValidSubprotocol,
failWebsocketConnection,
fireEvent
} = require('./util')
const { establishWebSocketConnection } = require('./connection')
const { establishWebSocketConnection, closeWebSocketConnection } = require('./connection')
const { WebsocketFrameSend } = require('./frame')
const { ByteParser } = require('./receiver')
const { kEnumerableProperty, isBlobLike } = require('../../core/util')
const { getGlobalDispatcher } = require('../../global')
const { types } = require('node:util')
const { ErrorEvent } = require('./events')

let experimentalWarned = false

Expand Down Expand Up @@ -197,67 +196,7 @@ class WebSocket extends EventTarget {
}

// 3. Run the first matching steps from the following list:
if (isClosing(this) || isClosed(this)) {
// If this's ready state is CLOSING (2) or CLOSED (3)
// Do nothing.
} else if (!isEstablished(this)) {
// If the WebSocket connection is not yet established
// Fail the WebSocket connection and set this's ready state
// to CLOSING (2).
failWebsocketConnection(this, 'Connection was closed before it was established.')
this[kReadyState] = WebSocket.CLOSING
} else if (this[kSentClose] === sentCloseFrameState.NOT_SENT) {
// If the WebSocket closing handshake has not yet been started
// Start the WebSocket closing handshake and set this's ready
// state to CLOSING (2).
// - If neither code nor reason is present, the WebSocket Close
// message must not have a body.
// - If code is present, then the status code to use in the
// WebSocket Close message must be the integer given by code.
// - If reason is also present, then reasonBytes must be
// provided in the Close message after the status code.

this[kSentClose] = sentCloseFrameState.PROCESSING

const frame = new WebsocketFrameSend()

// If neither code nor reason is present, the WebSocket Close
// message must not have a body.

// If code is present, then the status code to use in the
// WebSocket Close message must be the integer given by code.
if (code !== undefined && reason === undefined) {
frame.frameData = Buffer.allocUnsafe(2)
frame.frameData.writeUInt16BE(code, 0)
} else if (code !== undefined && reason !== undefined) {
// If reason is also present, then reasonBytes must be
// provided in the Close message after the status code.
frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength)
frame.frameData.writeUInt16BE(code, 0)
// the body MAY contain UTF-8-encoded data with value /reason/
frame.frameData.write(reason, 2, 'utf-8')
} else {
frame.frameData = emptyBuffer
}

/** @type {import('stream').Duplex} */
const socket = this[kResponse].socket

socket.write(frame.createFrame(opcodes.CLOSE), (err) => {
if (!err) {
this[kSentClose] = sentCloseFrameState.SENT
}
})

// Upon either sending or receiving a Close control frame, it is said
// that _The WebSocket Closing Handshake is Started_ and that the
// WebSocket connection is in the CLOSING state.
this[kReadyState] = states.CLOSING
} else {
// Otherwise
// Set this's ready state to CLOSING (2).
this[kReadyState] = WebSocket.CLOSING
}
closeWebSocketConnection(this, code, reason, reasonByteLength)
}

/**
Expand Down Expand Up @@ -521,9 +460,8 @@ class WebSocket extends EventTarget {
this[kResponse] = response

const parser = new ByteParser(this)
parser.on('drain', function onParserDrain () {
this.ws[kResponse].socket.resume()
})
parser.on('drain', onParserDrain)
parser.on('error', onParserError.bind(this))

response.socket.ws = this
this[kByteParser] = parser
Expand Down Expand Up @@ -647,6 +585,16 @@ webidl.converters.WebSocketSendData = function (V) {
return webidl.converters.USVString(V)
}

function onParserDrain () {
this.ws[kResponse].socket.resume()
}

function onParserError (err) {
fireEvent('error', this, () => new ErrorEvent('error', { error: err, message: err.reason }))

closeWebSocketConnection(this, err.code)
}

module.exports = {
WebSocket
}
45 changes: 45 additions & 0 deletions test/websocket/client-received-masked-frame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict'

const { test } = require('node:test')
const { once } = require('node:events')
const { WebSocketServer } = require('ws')
const { WebSocket } = require('../..')
const { tspl } = require('@matteo.collina/tspl')
const { WebsocketFrameSend } = require('../../lib/web/websocket/frame')

test('Client fails the connection if receiving a masked frame', async (t) => {
const assert = tspl(t, { plan: 2 })

const body = Buffer.allocUnsafe(2)
body.writeUInt16BE(1006, 0)

const frame = new WebsocketFrameSend(body)
const buffer = frame.createFrame(0x8)

const server = new WebSocketServer({ port: 0 })

server.on('connection', (ws) => {
const socket = ws._socket

socket.write(buffer, () => ws.close())
})

const ws = new WebSocket(`ws://localhost:${server.address().port}`)

ws.addEventListener('close', (e) => {
assert.deepStrictEqual(e.code, 1006)
})

ws.addEventListener('error', () => {
assert.ok(true)
})

t.after(() => {
server.close()
ws.close()
})

await once(ws, 'close')

await assert.completed
})
39 changes: 39 additions & 0 deletions test/websocket/close-invalid-status-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict'

const { test } = require('node:test')
const { once } = require('node:events')
const { WebSocketServer } = require('ws')
const { WebSocket } = require('../..')
const { tspl } = require('@matteo.collina/tspl')

test('Client fails the connection if receiving a masked frame', async (t) => {
const assert = tspl(t, { plan: 2 })

const server = new WebSocketServer({ port: 0 })

server.on('connection', (ws) => {
const socket = ws._socket

// 1006 status code
socket.write(Buffer.from([0x88, 0x02, 0x03, 0xee]), () => ws.close())
})

const ws = new WebSocket(`ws://localhost:${server.address().port}`)

ws.addEventListener('close', (e) => {
assert.deepStrictEqual(e.code, 1006)
})

ws.addEventListener('error', () => {
assert.ok(true)
})

t.after(() => {
server.close()
ws.close()
})

await once(ws, 'close')

await assert.completed
})
Loading
Loading