Skip to content

Commit 8cb10f9

Browse files
lpincamcollina
authored andcommitted
websocket: limit the number of fragments in a message
Introduce the `maxFragments` option to limit the number of fragments in a message. Without this limit, a remote peer could exploit structural wrapper overhead by sending many tiny fragments, consuming far more memory than the payload itself and crashing the process due to OOM. When the limit is hit, the connection is closed with close code 1008. Signed-off-by: Luigi Pinca <luigipinca@gmail.com> (cherry picked from commit 32dbf0b)
1 parent 04201f8 commit 8cb10f9

10 files changed

Lines changed: 146 additions & 10 deletions

File tree

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+
* **maxFragments** `number` (optional) - Defailt: `131072` - Maximum number of fragments in a message. 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/balanced-pool.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class BalancedPool extends PoolBase {
5454
throw new InvalidArgumentError('factory must be a function.')
5555
}
5656

57-
super()
57+
super(opts)
5858

5959
this[kOptions] = { ...util.deepClone(opts) }
6060
this[kOptions].interceptors = opts.interceptors

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
1111

1212
const kOnDestroyed = Symbol('onDestroyed')
1313
const kOnClosed = Symbol('onClosed')
14+
const kWebSocketOptions = Symbol('web socket options')
1415

1516
class DispatcherBase extends Dispatcher {
1617
/** @type {boolean} */
@@ -25,6 +26,20 @@ class DispatcherBase extends Dispatcher {
2526
/** @type {Array<Function>|null} */
2627
[kOnClosed] = null
2728

29+
/**
30+
* @param {{ webSocket?: { maxFragments?: number } }} [opts]
31+
*/
32+
constructor (opts) {
33+
super()
34+
this[kWebSocketOptions] = opts?.webSocket ?? {}
35+
}
36+
37+
get webSocketOptions () {
38+
return {
39+
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072
40+
}
41+
}
42+
2843
/** @returns {boolean} */
2944
get destroyed () {
3045
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/receiver.js

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

42+
/** @type {number} */
43+
#maxFragments
44+
4245
/**
4346
* @param {import('./websocket').Handler} handler
4447
* @param {Map<string, string>|null} extensions
48+
* @param {{ maxFragments?: 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.#maxFragments = options.maxFragments ?? 0
5156

5257
if (this.#extensions.has('permessage-deflate')) {
5358
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
@@ -212,7 +217,9 @@ class ByteParser extends Writable {
212217
this.#state = parserStates.INFO
213218
} else {
214219
if (!this.#info.compressed) {
215-
this.writeFragments(body)
220+
if (body.length && !this.writeFragments(body)) {
221+
return
222+
}
216223

217224
// If the frame is not fragmented, a message has been received.
218225
// If the frame is fragmented, it will terminate with a fin bit set
@@ -232,7 +239,9 @@ class ByteParser extends Writable {
232239
return
233240
}
234241

235-
this.writeFragments(data)
242+
if (data.length && !this.writeFragments(data)) {
243+
return
244+
}
236245

237246
if (!this.#info.fin) {
238247
this.#state = parserStates.INFO
@@ -305,8 +314,17 @@ class ByteParser extends Writable {
305314
}
306315

307316
writeFragments (fragment) {
317+
if (
318+
this.#maxFragments > 0 &&
319+
this.#fragments.length === this.#maxFragments
320+
) {
321+
failWebsocketConnection(this.#handler, 1008, 'Too many message fragments')
322+
return false
323+
}
324+
308325
this.#fragmentsBytes += fragment.length
309326
this.#fragments.push(fragment)
327+
return true
310328
}
311329

312330
consumeFragments () {

lib/web/websocket/websocket.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,11 @@ 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+
const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments
472+
473+
const parser = new ByteParser(this.#handler, parsedExtensions, {
474+
maxFragments
475+
})
472476
parser.on('drain', () => this.#handler.onParserDrain())
473477
parser.on('error', (err) => this.#handler.onParserError(err))
474478

test/websocket/fragments.js

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
const { test, after } = require('node:test')
44
const { WebSocketServer } = require('ws')
5-
const { WebSocket } = require('../..')
5+
const { Agent, WebSocket } = require('../..')
66
const diagnosticsChannel = require('node:diagnostics_channel')
77

88
test('Fragmented frame with a ping frame in the middle of it', (t) => {
@@ -39,3 +39,90 @@ test('Fragmented frame with a ping frame in the middle of it', (t) => {
3939
})
4040
})
4141
})
42+
43+
test('Too many fragments (uncompressed)', (t, done) => {
44+
t.plan(4)
45+
46+
const agent = new Agent({
47+
webSocket: {
48+
maxFragments: 3
49+
}
50+
})
51+
52+
const server = new WebSocketServer({ port: 0 }, () => {
53+
const { port } = server.address()
54+
const client = new WebSocket(`ws://127.0.0.1:${port}`, {
55+
dispatcher: agent
56+
})
57+
58+
client.addEventListener('error', (event) => {
59+
t.assert.ok(true)
60+
})
61+
62+
client.addEventListener('close', (event) => {
63+
t.assert.deepStrictEqual(event.code, 1006)
64+
})
65+
})
66+
67+
server.on('connection', (ws) => {
68+
ws.on('close', (code, reason) => {
69+
t.assert.deepStrictEqual(code, 1008)
70+
t.assert.deepStrictEqual(reason.toString(), 'Too many message fragments')
71+
agent.close()
72+
server.close(done)
73+
})
74+
75+
const fragment = Buffer.from('a')
76+
const options = { fin: false }
77+
78+
ws.send(fragment, options)
79+
ws.send(fragment, options)
80+
ws.send(fragment, options)
81+
ws.send(fragment, options)
82+
})
83+
})
84+
85+
test('Too many fragments (compressed)', (t, done) => {
86+
t.plan(4)
87+
88+
const agent = new Agent({
89+
webSocket: {
90+
maxFragments: 3
91+
}
92+
})
93+
94+
const server = new WebSocketServer({
95+
perMessageDeflate: { threshold: 0 },
96+
port: 0
97+
}, () => {
98+
const { port } = server.address()
99+
const client = new WebSocket(`ws://127.0.0.1:${port}`, {
100+
dispatcher: agent
101+
})
102+
103+
client.addEventListener('error', (event) => {
104+
t.assert.ok(true)
105+
})
106+
107+
client.addEventListener('close', (event) => {
108+
t.assert.deepStrictEqual(event.code, 1006)
109+
})
110+
})
111+
112+
server.on('connection', (ws) => {
113+
ws.on('close', (code, reason) => {
114+
t.assert.deepStrictEqual(code, 1008)
115+
t.assert.deepStrictEqual(reason.toString(), 'Too many message fragments')
116+
agent.close()
117+
server.close(done)
118+
})
119+
120+
const fragment = Buffer.from('a')
121+
const options = { fin: false }
122+
123+
ws.send(fragment, options)
124+
ws.send(fragment, options)
125+
ws.send(fragment, options)
126+
ws.send(fragment, options)
127+
})
128+
})

types/client.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export declare namespace Client {
107107
* @default 60000
108108
*/
109109
pingInterval?: number;
110+
/** WebSocket-specific configuration options. */
111+
webSocket?: WebSocketOptions;
110112
}
111113
export interface SocketInfo {
112114
localAddress?: string
@@ -118,6 +120,13 @@ export declare namespace Client {
118120
bytesWritten?: number
119121
bytesRead?: number
120122
}
123+
export interface WebSocketOptions {
124+
/**
125+
* Maximum number of fragments in a message. Set to 0 to disable the limit.
126+
* @default 131072
127+
*/
128+
maxFragments?: number;
129+
}
121130
}
122131

123132
export default Client

0 commit comments

Comments
 (0)