Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Returns: `Client`
* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds.
* **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB.
* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
* **maxPayloadSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to both uncompressed and decompressed (permessage-deflate) messages. For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit.
* **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections.
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
* **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source.
Expand Down
2 changes: 1 addition & 1 deletion lib/dispatcher/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
}

super()
super(options)

if (connect && typeof connect !== 'function') {
connect = { ...connect }
Expand Down
5 changes: 3 additions & 2 deletions lib/dispatcher/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ class Client extends DispatcherBase {
useH2c,
initialWindowSize,
connectionWindowSize,
pingInterval
pingInterval,
webSocket
} = {}) {
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
Expand Down Expand Up @@ -222,7 +223,7 @@ class Client extends DispatcherBase {
throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
}

super()
super({ webSocket })

if (typeof connect !== 'function') {
connect = buildConnector({
Expand Down
18 changes: 18 additions & 0 deletions lib/dispatcher/dispatcher-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy

const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kWebSocketOptions = Symbol('webSocketOptions')

class DispatcherBase extends Dispatcher {
/** @type {boolean} */
Expand All @@ -25,6 +26,23 @@ class DispatcherBase extends Dispatcher {
/** @type {Array<Function>|null} */
[kOnClosed] = null

/**
* @param {import('../../types/dispatcher').DispatcherOptions} [opts]
*/
constructor (opts) {
super()
this[kWebSocketOptions] = opts?.webSocket ?? {}
}

/**
* @returns {import('../../types/dispatcher').WebSocketOptions}
*/
get webSocketOptions () {
return {
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 64 * 1024 * 1024 // 64 MB default
}
}

/** @returns {boolean} */
get destroyed () {
return this[kDestroyed]
Expand Down
2 changes: 1 addition & 1 deletion lib/dispatcher/pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class Pool extends PoolBase {
})
}

super()
super(options)

this[kConnections] = connections || null
this[kUrl] = util.parseOrigin(origin)
Expand Down
44 changes: 39 additions & 5 deletions lib/web/websocket/permessage-deflate.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength')

// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
// Default maximum decompressed message size: 64 MB
const kDefaultMaxDecompressedSize = 64 * 1024 * 1024
// Maximum expansion ratio for estimated size check (conservative DEFLATE upper bound)
const kMaxExpansionRatio = 10

class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */
#inflate

#options = {}

/** @type {number} */
#maxDecompressedSize

/** @type {boolean} */
#aborted = false

Expand All @@ -25,18 +30,46 @@ class PerMessageDeflate {

/**
* @param {Map<string, string>} extensions
* @param {{ maxPayloadSize?: number }} [options]
*/
constructor (extensions) {
constructor (extensions, options = {}) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
// 0 disables the limit
this.#maxDecompressedSize = options.maxPayloadSize ?? kDefaultMaxDecompressedSize
}

/**
* Check if compressed payload could exceed the decompressed size limit.
* Uses a conservative expansion ratio estimate for early rejection.
* @param {number} compressedLength
* @returns {boolean} true if the message should be rejected
*/
#exceedsEstimatedLimit (compressedLength) {
// 0 disables the limit
if (this.#maxDecompressedSize <= 0) return false
return compressedLength * kMaxExpansionRatio > this.#maxDecompressedSize
}

decompress (chunk, fin, callback) {
/**
* Decompress a compressed payload.
* @param {Buffer} chunk Compressed data
* @param {boolean} fin Final fragment flag
* @param {Function} callback Callback function
* @param {number} [compressedLength] Compressed payload length for estimated size check
*/
decompress (chunk, fin, callback, compressedLength) {
// An endpoint uses the following algorithm to decompress a message.
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
// payload of the message.
// 2. Decompress the resulting data using DEFLATE.

// Early rejection based on estimated expansion
if (compressedLength != null && this.#exceedsEstimatedLimit(compressedLength)) {
callback(new MessageSizeExceededError())
return
}

if (this.#aborted) {
callback(new MessageSizeExceededError())
return
Expand Down Expand Up @@ -70,7 +103,8 @@ class PerMessageDeflate {

this.#inflate[kLength] += data.length

if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
// 0 disables the limit
if (this.#maxDecompressedSize > 0 && this.#inflate[kLength] > this.#maxDecompressedSize) {
this.#aborted = true
this.#inflate.removeAllListeners()
this.#inflate.destroy()
Expand Down
60 changes: 38 additions & 22 deletions lib/web/websocket/receiver.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,23 @@ class ByteParser extends Writable {
/** @type {import('./websocket').Handler} */
#handler

/** @type {number} */
#maxPayloadSize

/**
* @param {import('./websocket').Handler} handler
* @param {Map<string, string>|null} extensions
* @param {{ maxPayloadSize?: number }} [options]
*/
constructor (handler, extensions) {
constructor (handler, extensions, options = {}) {
super()

this.#handler = handler
this.#extensions = extensions == null ? new Map() : extensions
this.#maxPayloadSize = options.maxPayloadSize ?? 0

if (this.#extensions.has('permessage-deflate')) {
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
}
}

Expand Down Expand Up @@ -212,6 +217,12 @@ class ByteParser extends Writable {
this.#state = parserStates.INFO
} else {
if (!this.#info.compressed) {
// Check raw payload size before accepting uncompressed data
if (this.#maxPayloadSize > 0 && this.#info.payloadLength > this.#maxPayloadSize) {
failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size')
return
}

Comment on lines +220 to +225
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It might be better to validate the size as soon as the length is determined—whether it's the immediate 0-125 value or the subsequent 16/64-bit extended fields. This prevents us from proceeding to READ_DATA and wasting bandwidth on a request that's bound to fail. It feels a bit odd to wait for the actual data to arrive when the length from the header already provides sufficient grounds for rejection.

this.writeFragments(body)

// If the frame is not fragmented, a message has been received.
Expand All @@ -224,29 +235,34 @@ class ByteParser extends Writable {

this.#state = parserStates.INFO
} else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
if (error) {
// Use 1009 (Message Too Big) for decompression size limit errors
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnection(this.#handler, code, error.message)
return
}
this.#extensions.get('permessage-deflate').decompress(
body,
this.#info.fin,
(error, data) => {
if (error) {
// Use 1009 (Message Too Big) for decompression size limit errors
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnection(this.#handler, code, error.message)
return
}

this.writeFragments(data)

if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.run(callback)
return
}

websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())

this.writeFragments(data)

if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.#state = parserStates.INFO
this.run(callback)
return
}

websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())

this.#loop = true
this.#state = parserStates.INFO
this.run(callback)
})
},
this.#info.payloadLength
)

this.#loop = false
break
Expand Down
7 changes: 6 additions & 1 deletion lib/web/websocket/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,12 @@ class WebSocket extends EventTarget {
// once this happens, the connection is open
this.#handler.socket = response.socket

const parser = new ByteParser(this.#handler, parsedExtensions)
// Get maxPayloadSize from dispatcher options
const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize

const parser = new ByteParser(this.#handler, parsedExtensions, {
maxPayloadSize
})
parser.on('drain', () => this.#handler.onParserDrain())
parser.on('error', (err) => this.#handler.onParserError(err))

Expand Down
117 changes: 117 additions & 0 deletions test/websocket/permessage-deflate-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use strict'

const { test } = require('node:test')
const { once } = require('node:events')
const { WebSocketServer } = require('ws')
const { WebSocket, Agent, Client, Pool } = require('../..')

test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => {
const customLimit = 128 * 1024 * 1024 // 128 MB
const agent = new Agent({
webSocket: {
maxPayloadSize: customLimit
}
})

t.after(() => agent.close())

// Verify the option is stored and retrievable
t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit)
})

test('Agent with default webSocketOptions uses 64 MB limit', async (t) => {
const agent = new Agent()

t.after(() => agent.close())

// Default should be 64 MB
t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 64 * 1024 * 1024)
})

test('Custom maxPayloadSize allows messages under limit', async (t) => {
const server = new WebSocketServer({
port: 0,
perMessageDeflate: true
})

t.after(() => server.close())
await once(server, 'listening')

const dataSize = 512 * 1024 // 512 KB

server.on('connection', (ws) => {
ws.send(Buffer.alloc(dataSize, 0x41), { binary: true })
})

// Set custom limit of 1 MB via Agent
const agent = new Agent({
webSocket: {
maxPayloadSize: 1 * 1024 * 1024
}
})

t.after(() => agent.close())

const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent })

const [event] = await once(client, 'message')
t.assert.strictEqual(event.data.size, dataSize, 'Message under limit should be received')
client.close()
})

test('Messages at exactly the limit succeed', async (t) => {
const limit = 1 * 1024 * 1024 // 1 MB
const server = new WebSocketServer({
port: 0,
perMessageDeflate: true
})

t.after(() => server.close())
await once(server, 'listening')

server.on('connection', (ws) => {
ws.send(Buffer.alloc(limit, 0x41), { binary: true })
})

const agent = new Agent({
webSocket: {
maxPayloadSize: limit
}
})

t.after(() => agent.close())

const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent })

const [event] = await once(client, 'message')
t.assert.strictEqual(event.data.size, limit, 'Message at exactly the limit should succeed')
client.close()
})

test('Client webSocketOptions.maxPayloadSize is read correctly', async (t) => {
const customLimit = 32 * 1024 * 1024 // 32 MB
const client = new Client('http://localhost', {
webSocket: {
maxPayloadSize: customLimit
}
})

t.after(() => client.close())

// Verify the option is stored and retrievable
t.assert.strictEqual(client.webSocketOptions.maxPayloadSize, customLimit)
})

test('Pool webSocketOptions.maxPayloadSize is read correctly', async (t) => {
const customLimit = 16 * 1024 * 1024 // 16 MB
const pool = new Pool('http://localhost', {
webSocket: {
maxPayloadSize: customLimit
}
})

t.after(() => pool.close())

// Verify the option is stored and retrievable
t.assert.strictEqual(pool.webSocketOptions.maxPayloadSize, customLimit)
})
Loading
Loading