Skip to content

Commit 2ee00cb

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 <[email protected]>
1 parent 250efc8 commit 2ee00cb

File tree

10 files changed

+868
-9
lines changed

10 files changed

+868
-9
lines changed

PLAN.md

Lines changed: 435 additions & 0 deletions
Large diffs are not rendered by default.

docs/docs/api/Errors.md

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

3031
Be aware of the possible difference between the global dispatcher version and the actual undici version you might be using. We recommend to avoid the check `instanceof errors.UndiciError` and seek for the `error.code === '<error_code>'` instead to avoid inconsistencies.
3132
### `SocketError`

docs/docs/api/WebSocket.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ Arguments:
1111
* **url** `URL | string`
1212
* **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](/docs/docs/api/Dispatcher.md).
1313

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

1625
This example will not work in browsers or other platforms that don't allow passing an object.
@@ -34,6 +43,26 @@ import { WebSocket } from 'undici'
3443
const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
3544
```
3645

46+
### Example with custom decompression limit:
47+
48+
To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit:
49+
50+
```mjs
51+
import { WebSocket } from 'undici'
52+
53+
// Limit decompressed messages to 1 MB
54+
const ws = new WebSocket('wss://echo.websocket.events', {
55+
maxDecompressedMessageSize: 1 * 1024 * 1024
56+
})
57+
58+
ws.addEventListener('error', (event) => {
59+
// Connection will be closed if a message exceeds the limit
60+
console.error('WebSocket error:', event.error)
61+
})
62+
```
63+
64+
> ⚠️ **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.
65+
3766
### Example with HTTP/2:
3867

3968
> ⚠️ Warning: WebSocket over HTTP/2 is experimental, it is likely to change in the future.

lib/core/errors.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,24 @@ class MaxOriginsReachedError extends UndiciError {
421421
}
422422
}
423423

424+
const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED')
425+
class MessageSizeExceededError extends UndiciError {
426+
constructor (message) {
427+
super(message)
428+
this.name = 'MessageSizeExceededError'
429+
this.message = message || 'Max decompressed message size exceeded'
430+
this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
431+
}
432+
433+
static [Symbol.hasInstance] (instance) {
434+
return instance && instance[kMessageSizeExceededError] === true
435+
}
436+
437+
get [kMessageSizeExceededError] () {
438+
return true
439+
}
440+
}
441+
424442
module.exports = {
425443
AbortError,
426444
HTTPParserError,
@@ -444,5 +462,6 @@ module.exports = {
444462
RequestRetryError,
445463
ResponseError,
446464
SecureProxyConnectionError,
447-
MaxOriginsReachedError
465+
MaxOriginsReachedError,
466+
MessageSizeExceededError
448467
}

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
@@ -15,6 +15,7 @@ const {
1515
const { failWebsocketConnection } = require('./connection')
1616
const { WebsocketFrameSend } = require('./frame')
1717
const { PerMessageDeflate } = require('./permessage-deflate')
18+
const { MessageSizeExceededError } = require('../../core/errors')
1819

1920
// This code was influenced by ws released under the MIT license.
2021
// Copyright (c) 2011 Einar Otto Stangvik <[email protected]>
@@ -38,14 +39,23 @@ class ByteParser extends Writable {
3839
/** @type {import('./websocket').Handler} */
3940
#handler
4041

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

4453
this.#handler = handler
4554
this.#extensions = extensions == null ? new Map() : extensions
55+
this.#options = options
4656

4757
if (this.#extensions.has('permessage-deflate')) {
48-
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
58+
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
4959
}
5060
}
5161

@@ -222,7 +232,9 @@ class ByteParser extends Writable {
222232
} else {
223233
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
224234
if (error) {
225-
failWebsocketConnection(this.#handler, 1007, error.message)
235+
// Use 1009 (Message Too Big) for decompression size limit errors
236+
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
237+
failWebsocketConnection(this.#handler, code, error.message)
226238
return
227239
}
228240

lib/web/websocket/websocket.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ class WebSocket extends EventTarget {
109109
#binaryType
110110
/** @type {import('./receiver').ByteParser} */
111111
#parser
112+
/** @type {{ maxDecompressedMessageSize?: number }} */
113+
#options
112114

113115
/**
114116
* @param {string} url
@@ -154,6 +156,11 @@ class WebSocket extends EventTarget {
154156
// 5. Set this's url to urlRecord.
155157
this.#url = new URL(urlRecord.href)
156158

159+
// Store options for later use (e.g., maxDecompressedMessageSize)
160+
this.#options = {
161+
maxDecompressedMessageSize: options.maxDecompressedMessageSize
162+
}
163+
157164
// 6. Let client be this's relevant settings object.
158165
const client = environmentSettingsObject.settingsObject
159166

@@ -452,11 +459,11 @@ class WebSocket extends EventTarget {
452459
* @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
453460
*/
454461
#onConnectionEstablished (response, parsedExtensions) {
455-
// processResponse is called when the "responses header list has been received and initialized."
462+
// processResponse is called when the "response's header list has been received and initialized."
456463
// once this happens, the connection is open
457464
this.#handler.socket = response.socket
458465

459-
const parser = new ByteParser(this.#handler, parsedExtensions)
466+
const parser = new ByteParser(this.#handler, parsedExtensions, this.#options)
460467
parser.on('drain', () => this.#handler.onParserDrain())
461468
parser.on('error', (err) => this.#handler.onParserError(err))
462469

@@ -708,6 +715,19 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([
708715
{
709716
key: 'headers',
710717
converter: webidl.nullableConverter(webidl.converters.HeadersInit)
718+
},
719+
{
720+
key: 'maxDecompressedMessageSize',
721+
converter: webidl.nullableConverter((V) => {
722+
V = webidl.converters['unsigned long long'](V)
723+
if (V <= 0) {
724+
throw webidl.errors.exception({
725+
header: 'WebSocket constructor',
726+
message: 'maxDecompressedMessageSize must be greater than 0'
727+
})
728+
}
729+
return V
730+
})
711731
}
712732
])
713733

0 commit comments

Comments
 (0)