Skip to content

Commit bd91f86

Browse files
authored
feat: add configurable maxPayloadSize for WebSocket (#4955)
1 parent 8cfb762 commit bd91f86

File tree

11 files changed

+629
-59
lines changed

11 files changed

+629
-59
lines changed

docs/docs/api/Client.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Returns: `Client`
2424
* **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.
2525
* **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.
2626
* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
27+
* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
28+
* **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. Set to 0 to disable the limit.
2729
* **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.
2830
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
2931
* **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.

lib/dispatcher/agent.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class Agent extends DispatcherBase {
3535
throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
3636
}
3737

38-
super()
38+
super(options)
3939

4040
if (connect && typeof connect !== 'function') {
4141
connect = { ...connect }

lib/dispatcher/client.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ class Client extends DispatcherBase {
114114
useH2c,
115115
initialWindowSize,
116116
connectionWindowSize,
117-
pingInterval
117+
pingInterval,
118+
webSocket
118119
} = {}) {
119120
if (keepAlive !== undefined) {
120121
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -222,7 +223,7 @@ class Client extends DispatcherBase {
222223
throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
223224
}
224225

225-
super()
226+
super({ webSocket })
226227

227228
if (typeof connect !== 'function') {
228229
connect = buildConnector({

lib/dispatcher/dispatcher-base.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
1010

1111
const kOnDestroyed = Symbol('onDestroyed')
1212
const kOnClosed = Symbol('onClosed')
13+
const kWebSocketOptions = Symbol('webSocketOptions')
1314

1415
class DispatcherBase extends Dispatcher {
1516
/** @type {boolean} */
@@ -24,6 +25,23 @@ class DispatcherBase extends Dispatcher {
2425
/** @type {Array<Function>|null} */
2526
[kOnClosed] = null
2627

28+
/**
29+
* @param {import('../../types/dispatcher').DispatcherOptions} [opts]
30+
*/
31+
constructor (opts) {
32+
super()
33+
this[kWebSocketOptions] = opts?.webSocket ?? {}
34+
}
35+
36+
/**
37+
* @returns {import('../../types/dispatcher').WebSocketOptions}
38+
*/
39+
get webSocketOptions () {
40+
return {
41+
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default
42+
}
43+
}
44+
2745
/** @returns {boolean} */
2846
get destroyed () {
2947
return this[kDestroyed]

lib/dispatcher/pool.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class Pool extends PoolBase {
6363
})
6464
}
6565

66-
super()
66+
super(options)
6767

6868
this[kConnections] = connections || null
6969
this[kUrl] = util.parseOrigin(origin)

lib/web/websocket/permessage-deflate.js

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
88
const kBuffer = Symbol('kBuffer')
99
const kLength = Symbol('kLength')
1010

11-
// Default maximum decompressed message size: 4 MB
12-
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
13-
1411
class PerMessageDeflate {
1512
/** @type {import('node:zlib').InflateRaw} */
1613
#inflate
1714

1815
#options = {}
1916

20-
/** @type {boolean} */
21-
#aborted = false
22-
23-
/** @type {Function|null} */
24-
#currentCallback = null
17+
#maxPayloadSize = 0
2518

2619
/**
2720
* @param {Map<string, string>} extensions
2821
*/
29-
constructor (extensions) {
22+
constructor (extensions, options) {
3023
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
3124
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
25+
26+
this.#maxPayloadSize = options.maxPayloadSize
3227
}
3328

29+
/**
30+
* Decompress a compressed payload.
31+
* @param {Buffer} chunk Compressed data
32+
* @param {boolean} fin Final fragment flag
33+
* @param {Function} callback Callback function
34+
*/
3435
decompress (chunk, fin, callback) {
3536
// An endpoint uses the following algorithm to decompress a message.
3637
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
3738
// payload of the message.
3839
// 2. Decompress the resulting data using DEFLATE.
39-
40-
if (this.#aborted) {
41-
callback(new MessageSizeExceededError())
42-
return
43-
}
44-
4540
if (!this.#inflate) {
4641
let windowBits = Z_DEFAULT_WINDOWBITS
4742

@@ -64,23 +59,12 @@ class PerMessageDeflate {
6459
this.#inflate[kLength] = 0
6560

6661
this.#inflate.on('data', (data) => {
67-
if (this.#aborted) {
68-
return
69-
}
70-
7162
this.#inflate[kLength] += data.length
7263

73-
if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
74-
this.#aborted = true
64+
if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
65+
callback(new MessageSizeExceededError())
7566
this.#inflate.removeAllListeners()
76-
this.#inflate.destroy()
7767
this.#inflate = null
78-
79-
if (this.#currentCallback) {
80-
const cb = this.#currentCallback
81-
this.#currentCallback = null
82-
cb(new MessageSizeExceededError())
83-
}
8468
return
8569
}
8670

@@ -93,22 +77,20 @@ class PerMessageDeflate {
9377
})
9478
}
9579

96-
this.#currentCallback = callback
9780
this.#inflate.write(chunk)
9881
if (fin) {
9982
this.#inflate.write(tail)
10083
}
10184

10285
this.#inflate.flush(() => {
103-
if (this.#aborted || !this.#inflate) {
86+
if (!this.#inflate) {
10487
return
10588
}
10689

10790
const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength])
10891

10992
this.#inflate[kBuffer].length = 0
11093
this.#inflate[kLength] = 0
111-
this.#currentCallback = null
11294

11395
callback(null, full)
11496
})

lib/web/websocket/receiver.js

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,23 @@ class ByteParser extends Writable {
3939
/** @type {import('./websocket').Handler} */
4040
#handler
4141

42+
/** @type {number} */
43+
#maxPayloadSize
44+
4245
/**
4346
* @param {import('./websocket').Handler} handler
4447
* @param {Map<string, string>|null} extensions
48+
* @param {{ maxPayloadSize?: number }} [options]
4549
*/
46-
constructor (handler, extensions) {
50+
constructor (handler, extensions, options = {}) {
4751
super()
4852

4953
this.#handler = handler
5054
this.#extensions = extensions == null ? new Map() : extensions
55+
this.#maxPayloadSize = options.maxPayloadSize ?? 0
5156

5257
if (this.#extensions.has('permessage-deflate')) {
53-
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
58+
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
5459
}
5560
}
5661

@@ -66,6 +71,19 @@ class ByteParser extends Writable {
6671
this.run(callback)
6772
}
6873

74+
#validatePayloadLength () {
75+
if (
76+
this.#maxPayloadSize > 0 &&
77+
!isControlFrame(this.#info.opcode) &&
78+
this.#info.payloadLength > this.#maxPayloadSize
79+
) {
80+
failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size')
81+
return false
82+
}
83+
84+
return true
85+
}
86+
6987
/**
7088
* Runs whenever a new chunk is received.
7189
* Callback is called whenever there are no more chunks buffering,
@@ -154,6 +172,10 @@ class ByteParser extends Writable {
154172
if (payloadLength <= 125) {
155173
this.#info.payloadLength = payloadLength
156174
this.#state = parserStates.READ_DATA
175+
176+
if (!this.#validatePayloadLength()) {
177+
return
178+
}
157179
} else if (payloadLength === 126) {
158180
this.#state = parserStates.PAYLOADLENGTH_16
159181
} else if (payloadLength === 127) {
@@ -178,6 +200,10 @@ class ByteParser extends Writable {
178200

179201
this.#info.payloadLength = buffer.readUInt16BE(0)
180202
this.#state = parserStates.READ_DATA
203+
204+
if (!this.#validatePayloadLength()) {
205+
return
206+
}
181207
} else if (this.#state === parserStates.PAYLOADLENGTH_64) {
182208
if (this.#byteOffset < 8) {
183209
return callback()
@@ -200,6 +226,10 @@ class ByteParser extends Writable {
200226

201227
this.#info.payloadLength = lower
202228
this.#state = parserStates.READ_DATA
229+
230+
if (!this.#validatePayloadLength()) {
231+
return
232+
}
203233
} else if (this.#state === parserStates.READ_DATA) {
204234
if (this.#byteOffset < this.#info.payloadLength) {
205235
return callback()
@@ -224,29 +254,39 @@ class ByteParser extends Writable {
224254

225255
this.#state = parserStates.INFO
226256
} else {
227-
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
228-
if (error) {
229-
// Use 1009 (Message Too Big) for decompression size limit errors
230-
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
231-
failWebsocketConnection(this.#handler, code, error.message)
232-
return
233-
}
234-
235-
this.writeFragments(data)
257+
this.#extensions.get('permessage-deflate').decompress(
258+
body,
259+
this.#info.fin,
260+
(error, data) => {
261+
if (error) {
262+
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
263+
failWebsocketConnection(this.#handler, code, error.message)
264+
return
265+
}
266+
267+
this.writeFragments(data)
268+
269+
// Check cumulative fragment size
270+
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
271+
failWebsocketConnection(this.#handler, 1009, new MessageSizeExceededError().message)
272+
return
273+
}
274+
275+
if (!this.#info.fin) {
276+
this.#state = parserStates.INFO
277+
this.#loop = true
278+
this.run(callback)
279+
return
280+
}
281+
282+
websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())
236283

237-
if (!this.#info.fin) {
238-
this.#state = parserStates.INFO
239284
this.#loop = true
285+
this.#state = parserStates.INFO
240286
this.run(callback)
241-
return
242-
}
243-
244-
websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())
245-
246-
this.#loop = true
247-
this.#state = parserStates.INFO
248-
this.run(callback)
249-
})
287+
},
288+
this.#fragmentsBytes
289+
)
250290

251291
this.#loop = false
252292
break

lib/web/websocket/websocket.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,12 @@ class WebSocket extends EventTarget {
468468
// once this happens, the connection is open
469469
this.#handler.socket = response.socket
470470

471-
const parser = new ByteParser(this.#handler, parsedExtensions)
471+
// Get maxPayloadSize from dispatcher options
472+
const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize
473+
474+
const parser = new ByteParser(this.#handler, parsedExtensions, {
475+
maxPayloadSize
476+
})
472477
parser.on('drain', () => this.#handler.onParserDrain())
473478
parser.on('error', (err) => this.#handler.onParserError(err))
474479

0 commit comments

Comments
 (0)