Skip to content

Commit 4b4f93a

Browse files
committed
fix(websocket): add maxDecompressedMessageSize limit for permessage-deflate
Add protection against decompression bomb attacks in WebSocket permessage-deflate extension. A malicious server could send a small compressed payload that expands to an extremely large size, causing memory exhaustion. Changes: - Add maxDecompressedMessageSize option to WebSocket constructor - Default limit: 4 MB - Abort decompression immediately when limit exceeded - Close connection with status code 1009 (Message Too Big) - Add MessageSizeExceededError (UND_ERR_WS_MESSAGE_SIZE_EXCEEDED) - Add comprehensive tests for the new limit behavior - Update TypeScript types and documentation Signed-off-by: Matteo Collina <hello@matteocollina.com> (cherry picked from commit 2ee00cb)
1 parent fbc31e2 commit 4b4f93a

File tree

9 files changed

+428
-9
lines changed

9 files changed

+428
-9
lines changed

docs/docs/api/Errors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { errors } from 'undici'
2727
| `InformationalError` | `UND_ERR_INFO` | expected error with reason |
2828
| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
2929
| `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
30+
| `MessageSizeExceededError` | `UND_ERR_WS_MESSAGE_SIZE_EXCEEDED` | WebSocket decompressed message exceeded the maximum allowed size |
3031

3132
### `SocketError`
3233

docs/docs/api/WebSocket.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ Arguments:
1313
* **url** `URL | string` - The url's protocol *must* be `ws` or `wss`.
1414
* **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](./Dispatcher.md).
1515

16+
### WebSocketInit
17+
18+
When passing an object as the second argument, the following options are available:
19+
20+
* **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use.
21+
* **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection.
22+
* **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request.
23+
* **maxDecompressedMessageSize** `number` (optional) - Maximum allowed size in bytes for decompressed messages when using the `permessage-deflate` extension. **Default:** `4194304` (4 MB).
24+
1625
### Example:
1726

1827
This example will not work in browsers or other platforms that don't allow passing an object.
@@ -36,6 +45,20 @@ import { WebSocket } from 'undici'
3645
const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
3746
```
3847

48+
### Example with custom decompression limit:
49+
50+
To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit:
51+
52+
```mjs
53+
import { WebSocket } from 'undici'
54+
55+
const ws = new WebSocket('wss://echo.websocket.events', {
56+
maxDecompressedMessageSize: 1 * 1024 * 1024
57+
})
58+
```
59+
60+
> ⚠️ **Security Note**: The `maxDecompressedMessageSize` option protects against memory exhaustion attacks where a malicious server sends a small compressed payload that decompresses to an extremely large size. If you increase this limit significantly above the default, ensure your application can handle the increased memory usage.
61+
3962
## Read More
4063

4164
- [MDN - WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)

lib/core/errors.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,24 @@ class SecureProxyConnectionError extends UndiciError {
379379
[kSecureProxyConnectionError] = true
380380
}
381381

382+
const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED')
383+
class MessageSizeExceededError extends UndiciError {
384+
constructor (message) {
385+
super(message)
386+
this.name = 'MessageSizeExceededError'
387+
this.message = message || 'Max decompressed message size exceeded'
388+
this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
389+
}
390+
391+
static [Symbol.hasInstance] (instance) {
392+
return instance && instance[kMessageSizeExceededError] === true
393+
}
394+
395+
get [kMessageSizeExceededError] () {
396+
return true
397+
}
398+
}
399+
382400
module.exports = {
383401
AbortError,
384402
HTTPParserError,
@@ -402,5 +420,6 @@ module.exports = {
402420
ResponseExceededMaxSizeError,
403421
RequestRetryError,
404422
ResponseError,
405-
SecureProxyConnectionError
423+
SecureProxyConnectionError,
424+
MessageSizeExceededError
406425
}

lib/web/websocket/permessage-deflate.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,38 @@
22

33
const { createInflateRaw, Z_DEFAULT_WINDOWBITS } = require('node:zlib')
44
const { isValidClientWindowBits } = require('./util')
5+
const { MessageSizeExceededError } = require('../../core/errors')
56

67
const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
78
const kBuffer = Symbol('kBuffer')
89
const kLength = Symbol('kLength')
910

11+
// Default maximum decompressed message size: 4 MB
12+
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
13+
1014
class PerMessageDeflate {
1115
/** @type {import('node:zlib').InflateRaw} */
1216
#inflate
1317

1418
#options = {}
1519

16-
constructor (extensions) {
20+
/** @type {number} */
21+
#maxDecompressedSize
22+
23+
/** @type {boolean} */
24+
#aborted = false
25+
26+
/** @type {Function|null} */
27+
#currentCallback = null
28+
29+
/**
30+
* @param {Map<string, string>} extensions
31+
* @param {{ maxDecompressedMessageSize?: number }} [options]
32+
*/
33+
constructor (extensions, options = {}) {
1734
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
1835
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
36+
this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize
1937
}
2038

2139
decompress (chunk, fin, callback) {
@@ -24,6 +42,11 @@ class PerMessageDeflate {
2442
// payload of the message.
2543
// 2. Decompress the resulting data using DEFLATE.
2644

45+
if (this.#aborted) {
46+
callback(new MessageSizeExceededError())
47+
return
48+
}
49+
2750
if (!this.#inflate) {
2851
let windowBits = Z_DEFAULT_WINDOWBITS
2952

@@ -41,8 +64,27 @@ class PerMessageDeflate {
4164
this.#inflate[kLength] = 0
4265

4366
this.#inflate.on('data', (data) => {
44-
this.#inflate[kBuffer].push(data)
67+
if (this.#aborted) {
68+
return
69+
}
70+
4571
this.#inflate[kLength] += data.length
72+
73+
if (this.#inflate[kLength] > this.#maxDecompressedSize) {
74+
this.#aborted = true
75+
this.#inflate.removeAllListeners()
76+
this.#inflate.destroy()
77+
this.#inflate = null
78+
79+
if (this.#currentCallback) {
80+
const cb = this.#currentCallback
81+
this.#currentCallback = null
82+
cb(new MessageSizeExceededError())
83+
}
84+
return
85+
}
86+
87+
this.#inflate[kBuffer].push(data)
4688
})
4789

4890
this.#inflate.on('error', (err) => {
@@ -51,16 +93,22 @@ class PerMessageDeflate {
5193
})
5294
}
5395

96+
this.#currentCallback = callback
5497
this.#inflate.write(chunk)
5598
if (fin) {
5699
this.#inflate.write(tail)
57100
}
58101

59102
this.#inflate.flush(() => {
103+
if (this.#aborted || !this.#inflate) {
104+
return
105+
}
106+
60107
const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength])
61108

62109
this.#inflate[kBuffer].length = 0
63110
this.#inflate[kLength] = 0
111+
this.#currentCallback = null
64112

65113
callback(null, full)
66114
})

lib/web/websocket/receiver.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
const { WebsocketFrameSend } = require('./frame')
1919
const { closeWebSocketConnection } = require('./connection')
2020
const { PerMessageDeflate } = require('./permessage-deflate')
21+
const { MessageSizeExceededError } = require('../../core/errors')
2122

2223
// This code was influenced by ws released under the MIT license.
2324
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -37,14 +38,23 @@ class ByteParser extends Writable {
3738
/** @type {Map<string, PerMessageDeflate>} */
3839
#extensions
3940

40-
constructor (ws, extensions) {
41+
/** @type {{ maxDecompressedMessageSize?: number }} */
42+
#options
43+
44+
/**
45+
* @param {import('./websocket').WebSocket} ws
46+
* @param {Map<string, string>|null} extensions
47+
* @param {{ maxDecompressedMessageSize?: number }} [options]
48+
*/
49+
constructor (ws, extensions, options = {}) {
4150
super()
4251

4352
this.ws = ws
4453
this.#extensions = extensions == null ? new Map() : extensions
54+
this.#options = options
4555

4656
if (this.#extensions.has('permessage-deflate')) {
47-
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
57+
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
4858
}
4959
}
5060

@@ -223,7 +233,9 @@ class ByteParser extends Writable {
223233
} else {
224234
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
225235
if (error) {
226-
closeWebSocketConnection(this.ws, 1007, error.message, error.message.length)
236+
// Use 1009 (Message Too Big) for decompression size limit errors
237+
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
238+
closeWebSocketConnection(this.ws, code, error.message, error.message.length)
227239
return
228240
}
229241

lib/web/websocket/websocket.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class WebSocket extends EventTarget {
4444
/** @type {SendQueue} */
4545
#sendQueue
4646

47+
/** @type {{ maxDecompressedMessageSize?: number }} */
48+
#options
49+
4750
/**
4851
* @param {string} url
4952
* @param {string|string[]} protocols
@@ -117,6 +120,11 @@ class WebSocket extends EventTarget {
117120
// 10. Set this's url to urlRecord.
118121
this[kWebSocketURL] = new URL(urlRecord.href)
119122

123+
// Store options for later use (e.g., maxDecompressedMessageSize)
124+
this.#options = {
125+
maxDecompressedMessageSize: options.maxDecompressedMessageSize
126+
}
127+
120128
// 11. Let client be this's relevant settings object.
121129
const client = environmentSettingsObject.settingsObject
122130

@@ -431,11 +439,11 @@ class WebSocket extends EventTarget {
431439
* @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
432440
*/
433441
#onConnectionEstablished (response, parsedExtensions) {
434-
// processResponse is called when the "responses header list has been received and initialized."
442+
// processResponse is called when the "response's header list has been received and initialized."
435443
// once this happens, the connection is open
436444
this[kResponse] = response
437445

438-
const parser = new ByteParser(this, parsedExtensions)
446+
const parser = new ByteParser(this, parsedExtensions, this.#options)
439447
parser.on('drain', onParserDrain)
440448
parser.on('error', onParserError.bind(this))
441449

@@ -538,6 +546,19 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([
538546
{
539547
key: 'headers',
540548
converter: webidl.nullableConverter(webidl.converters.HeadersInit)
549+
},
550+
{
551+
key: 'maxDecompressedMessageSize',
552+
converter: webidl.nullableConverter((V) => {
553+
V = webidl.converters['unsigned long long'](V)
554+
if (V <= 0) {
555+
throw webidl.errors.exception({
556+
header: 'WebSocket constructor',
557+
message: 'maxDecompressedMessageSize must be greater than 0'
558+
})
559+
}
560+
return V
561+
})
541562
}
542563
])
543564

0 commit comments

Comments
 (0)