Skip to content

feat: add configurable maxPayloadSize for WebSocket#4955

Open
mcollina wants to merge 3 commits intomainfrom
feature/configurable-max-decompressed-message-size
Open

feat: add configurable maxPayloadSize for WebSocket#4955
mcollina wants to merge 3 commits intomainfrom
feature/configurable-max-decompressed-message-size

Conversation

@mcollina
Copy link
Copy Markdown
Member

@mcollina mcollina commented Apr 2, 2026

Fixes #4944

Changes

  • Added dispatcher-level webSocketOptions.maxPayloadSize configuration
  • Default: 64 MB for both compressed and uncompressed payloads
  • Add estimated expansion check (10x ratio) for decompression bomb protection
  • Add raw payload size check before accepting uncompressed data
  • Fixed actual size tracking to use configured limit

Usage

import { Agent, WebSocket } from 'undici'

const agent = new Agent({
  webSocket: {
    maxPayloadSize: 128 * 1024 * 1024 // 128 MB
  }
})

const ws = new WebSocket('wss://example.com', { dispatcher: agent })

- Add webSocketOptions.maxDecompressedMessageSize to DispatcherBase
- Propagate through Agent, Client, Pool
- Increase default from 4 MB to 64 MB
- Add estimated expansion check (10x ratio)
- Fix actual size tracking to use configured limit
- Add tests
@mcollina mcollina changed the title feat: add configurable maxDecompressedMessageSize for WebSocket feat: add configurable maxDecompressedMessageSize for WebSocket in Agent Apr 2, 2026
@mcollina mcollina requested review from KhafraDev, ronag and tsctx April 2, 2026 05:51
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 92.45283% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.03%. Comparing base (84f23e2) to head (b8b6b2c).
⚠️ Report is 30 commits behind head on main.

Files with missing lines Patch % Lines
lib/web/websocket/receiver.js 86.84% 5 Missing ⚠️
lib/web/websocket/permessage-deflate.js 92.30% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4955      +/-   ##
==========================================
+ Coverage   92.93%   93.03%   +0.09%     
==========================================
  Files         112      111       -1     
  Lines       35725    35938     +213     
==========================================
+ Hits        33202    33434     +232     
+ Misses       2523     2504      -19     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Member

@KhafraDev KhafraDev left a comment

Choose a reason for hiding this comment

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

My issues are still present. By default, code that works in ws, Bun, deno, firefox, chrome, Safari, and every other browser and environment will fail in node.

@mcollina
Copy link
Copy Markdown
Member Author

mcollina commented Apr 2, 2026

My issues are still present. By default, code that works in ws, Bun, deno, firefox, chrome, Safari, and every other browser and environment will fail in node.

The security model of Firefox, Chrome and Safari is radically different from what we can offer in Node.js. The challenges are sometimes opposite.

Out-of-memory errors cannot be easily caught by Node.js and V8 (Chrome can just crash a tab) and they are handled as best-effort cases. The only safe way to prevent a crash is to provide a solution to avoid allocating that memory.

I will investigate Bun and Deno if they deal with this in some different way.

@KhafraDev
Copy link
Copy Markdown
Member

Different security models, but the same spec. ws added a maxPayloadSize option 2 weeks ago (possibly inspired by undici's CVE?) that is disabled by default as well.

@mcollina
Copy link
Copy Markdown
Member Author

mcollina commented Apr 2, 2026

I did a pass over what limits other WebSocket implementations actually expose, because RFC 6455 leaves this up to implementations.

A few things seem worth separating:

  • frame / message limits
  • compressed vs decompressed limits
  • documented public API limits vs implementation details

1) What the spec says

RFC 6455 explicitly says this is implementation-specific. In §10.4:

Implementations that have implementation- and/or platform-specific limitations regarding the frame size or total message size after reassembly from multiple frames MUST protect themselves against exceeding those limits ... Such an implementation SHOULD impose a limit on frame sizes and the total message size after reassembly from multiple frames.

Ref: https://www.rfc-editor.org/rfc/rfc6455#section-10.4

So the spec does not define one universal limit, but it does explicitly expect implementations to protect themselves.

2) Browser-style WebSocket APIs

For Chrome / Firefox / Safari, I couldn't find an official public doc that says “the maximum incoming WebSocket message size is N bytes”. What MDN does document for the standard browser WebSocket API is that it has no backpressure, so it can fill memory if messages arrive faster than the application can process them:

Deno's browser-style WebSocket docs are similar: they expose the standard API, but I couldn't find a documented numeric incoming-message limit there either:

So for browser-style APIs, the public contract appears to be closer to "implementation / memory dependent" than to a stable documented numeric cap.

3) Server/runtime implementations that do expose explicit limits

These are the clearest documented limits I found:

4) What this means for undici

The cross-implementation picture looks like this:

  • The spec expects implementations to defend themselves.
  • Browsers don’t seem to publish a stable numeric limit in their public WebSocket API docs.
  • Server/runtime libraries usually do expose an explicit configurable limit.
  • The defaults vary a lot: from 16 KiB (uWS) to 100 MiB (ws).

Also, most of the limits above are message-size limits in general, not specifically decompressed-message-size limits. In that sense, what this PR adds is actually more targeted than many existing APIs, because it directly addresses the permessage-deflate expansion case instead of only limiting wire payload size.

So IMO this PR is well aligned with the ecosystem:

  1. RFC 6455 says implementations should protect themselves.
  2. Server-side WebSocket stacks commonly expose a configurable limit.
  3. There is no obvious single "everyone uses X" default across the ecosystem anyway.
  4. A configurable decompressed size limit is a reasonable server/runtime safeguard, especially in Node where OOM is process-fatal rather than "just crash a tab".

@KhafraDev
Copy link
Copy Markdown
Member

KhafraDev commented Apr 2, 2026

Note that permessage-deflate is standardized in RFC 7692 and not RFC 6455

- Set maxDecompressedMessageSize to 0 to disable the limit
- Default remains 64 MB for decompression bomb protection
- Add test for disabled limit
@mcollina
Copy link
Copy Markdown
Member Author

mcollina commented Apr 3, 2026

I doesn't matter if it's server or client. If one is receiving data from untrusted sources, they are vulnerable to this attack (in Node.js).

I'll keep digging on more proof points.

@mcollina
Copy link
Copy Markdown
Member Author

mcollina commented Apr 3, 2026

A bit more data after testing other runtimes in the same setup (host ws server, perMessageDeflate: true, client in a 256MB memory-capped Docker container):

  • Bun 1.3.11 client negotiated permessage-deflate and I could reproduce the same broad failure mode as Node: 64 MiB and 80 MiB succeeded, but 96 MiB and 128 MiB caused the client process to be killed with exit 137. On the server side that showed up as close code 1006.

  • Deno 2.7.11 client behaved differently: it did not negotiate permessage-deflate at all in this repro, so it doesn't exercise the decompression path. Instead it has an explicit size limit and fails cleanly with Error: Frame too large.

    • Empirically, 67108863 bytes succeeds and 67108864 bytes fails.
    • That matches Deno's current implementation, which uses fastwebsockets; fastwebsockets defaults max_message_size to 64 << 20 and rejects when payload_len >= self.max_message_size.
    • It also documents that permessage-deflate is not supported yet.

References:

Given that, one question: would it make sense to also consider a more general maxPayload / maxMessageSize style option, in addition to (or instead of) maxDecompressedMessageSize?

My intuition is that maxDecompressedMessageSize is the right targeted fix for the expansion problem behind this issue, but a generic payload/message limit may be a better overall protection story. At the same time, without compression-driven expansion it probably isn't in the same DoS/CVE class.

@mcollina
Copy link
Copy Markdown
Member Author

mcollina commented Apr 3, 2026

I'm also ok in raising the limit to 64MB, I was too conservative with 4MB

@tsctx
Copy link
Copy Markdown
Member

tsctx commented Apr 3, 2026

I'm inclined to think we should be cautious about introducing limits like 64MB that aren’t explicitly in the specification. Since the client can't currently restrict uncompressed messages, limiting only the compressed path might not provide full protection against OOM crashes; raw data could still potentially be streamed up to the 2GB limit.

Unless we can implement a unified maxPayloadSize for both paths, it might be more consistent to default to the protocol or platform maximum (approx. 2GB). It may be better to let developers configure specific restrictions themselves, rather than having the library impose caps that aren't standard in browser environments.

@lpinca
Copy link
Copy Markdown
Member

lpinca commented Apr 3, 2026

ws added a maxPayloadSize option 2 weeks ago

No, it is there since forever, the commit you are referring to is just moving a positional argument to an option.

ws' maxPayload option: I believe 100 MB limit is server-only. For the client, it is disabled by default.

No, it works in the same way for both the client and the server. 0 means no limit.

The extension is enabled by default on the client and is disabled by default on the server due to memory fragmentation issues, see https://github.com/websockets/ws?tab=readme-ov-file#websocket-compression. Anyway a lot has changed/improved since the defaults decision and it is now used in many production environments.

You can count the size of the chunks in the listener of the 'data' event of the zlib.InflateRaw stream to prevent "zip bombs", see https://github.com/websockets/ws/blob/84392554/lib/permessage-deflate.js#L482-L506.

@KhafraDev
Copy link
Copy Markdown
Member

No, it works in the same way for both the client and the server. 0 means no limit.

My bad, in jsdoc [param=value] means value is the default (https://jsdoc.app/tags-param#optional-parameters-and-default-values)

You can count the size of the chunks in the listener of the 'data' event of the zlib.InflateRaw stream to prevent "zip bombs"

This is what the PR is doing.

@lpinca
Copy link
Copy Markdown
Member

lpinca commented Apr 3, 2026

in jsdoc [param=value] means value is the default

Yes, but the class is not instantiated with that value (https://github.com/websockets/ws/blob/84392554/lib/websocket-server.js#L299, https://github.com/websockets/ws/blob/84392554/lib/websocket.js#L761).

- Apply limit to both compressed and uncompressed payloads
- Add raw payload size check before accepting uncompressed data
- Update types and docs to reflect new option name
- Add test for raw uncompressed payload limit enforcement
@mcollina mcollina changed the title feat: add configurable maxDecompressedMessageSize for WebSocket in Agent feat: add configurable maxPayloadSize for WebSocket Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow configuring maxDecompressedMessageSize

5 participants