node_modules: update (#146)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurla
2026-08-07 08:08:41 +02:00
committed by GitHub
co-authored by dawidd6
parent 6b3dfab4e0
commit 3d5a3a75cc
44 changed files with 660 additions and 284 deletions
+3
View File
@@ -26,6 +26,9 @@ Returns: `Client`
* **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.
* **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.
* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
* **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
* **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.
* **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.
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
* **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.
-15
View File
@@ -20,7 +20,6 @@ When passing an object as the second argument, the following options are availab
* **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use.
* **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection.
* **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request.
* **maxDecompressedMessageSize** `number` (optional) - Maximum allowed size in bytes for decompressed messages when using the `permessage-deflate` extension. **Default:** `4194304` (4 MB).
### Example:
@@ -45,20 +44,6 @@ import { WebSocket } from 'undici'
const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
```
### Example with custom decompression limit:
To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit:
```mjs
import { WebSocket } from 'undici'
const ws = new WebSocket('wss://echo.websocket.events', {
maxDecompressedMessageSize: 1 * 1024 * 1024
})
```
> ⚠️ **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.
## Read More
- [MDN - WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
+12 -1
View File
@@ -350,7 +350,13 @@ function processHeader (request, key, val) {
} else if (typeof val[i] === 'object') {
throw new InvalidArgumentError(`invalid ${key} header`)
} else {
arr.push(`${val[i]}`)
// Coerce primitives (and reject unsafe coercions such as functions
// with a crafted toString/Symbol.toPrimitive).
const str = `${val[i]}`
if (!isValidHeaderValue(str)) {
throw new InvalidArgumentError(`invalid ${key} header`)
}
arr.push(str)
}
}
val = arr
@@ -361,7 +367,12 @@ function processHeader (request, key, val) {
} else if (val === null) {
val = ''
} else {
// Coerce primitives (and reject unsafe coercions such as functions
// with a crafted toString/Symbol.toPrimitive).
val = `${val}`
if (!isValidHeaderValue(val)) {
throw new InvalidArgumentError(`invalid ${key} header`)
}
}
if (headerName === 'host') {
+2 -2
View File
@@ -24,8 +24,6 @@ function defaultFactory (origin, opts) {
class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
super()
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
@@ -38,6 +36,8 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('maxRedirections must be a positive number')
}
super(options)
if (connect && typeof connect !== 'function') {
connect = { ...connect }
}
+151 -25
View File
@@ -10,6 +10,7 @@ const {
RequestContentLengthMismatchError,
ResponseContentLengthMismatchError,
RequestAbortedError,
InvalidArgumentError,
HeadersTimeoutError,
HeadersOverflowError,
SocketError,
@@ -57,6 +58,9 @@ const EMPTY_BUF = Buffer.alloc(0)
const FastBuffer = Buffer[Symbol.species]
const addListener = util.addListener
const removeAllListeners = util.removeAllListeners
const kIdleSocketValidation = Symbol('kIdleSocketValidation')
const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
const kSocketUsed = Symbol('kSocketUsed')
let extractBody
@@ -279,29 +283,71 @@ class Parser {
const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(data.slice(offset))
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(data.slice(offset))
} else if (ret !== constants.ERROR.OK) {
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
/* istanbul ignore else: difficult to make a test case for */
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
if (ret !== constants.ERROR.OK) {
const body = data.subarray(offset)
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(body)
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(body)
} else {
throw this.createError(ret, body)
}
throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
}
} catch (err) {
util.destroy(socket, err)
}
}
finish () {
assert(currentParser === null)
assert(this.ptr != null)
assert(!this.paused)
const { llhttp } = this
let ret
try {
currentParser = this
ret = llhttp.llhttp_finish(this.ptr)
} finally {
currentParser = null
}
if (ret === constants.ERROR.OK) {
return null
}
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
this.paused = true
return null
}
return this.createError(ret, EMPTY_BUF)
}
createError (ret, data) {
const { llhttp, contentLength, bytesRead } = this
if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
return new ResponseContentLengthMismatchError()
}
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
}
return new HTTPParserError(message, constants.ERROR[ret], data)
}
destroy () {
assert(this.ptr != null)
assert(currentParser == null)
@@ -329,6 +375,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
if (!request) {
return -1
@@ -432,6 +483,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
/* istanbul ignore next: difficult to make a test case for */
@@ -605,6 +661,7 @@ class Parser {
request.onComplete(headers)
client[kQueue][client[kRunningIdx]++] = null
socket[kSocketUsed] = true
if (socket[kWriting]) {
assert(client[kRunning] === 0)
@@ -663,6 +720,9 @@ async function connectH1 (client, socket) {
socket[kWriting] = false
socket[kReset] = false
socket[kBlocking] = false
socket[kIdleSocketValidation] = 0
socket[kIdleSocketValidationTimeout] = null
socket[kSocketUsed] = false
socket[kParser] = new Parser(client, socket, llhttpInstance)
addListener(socket, 'error', function (err) {
@@ -673,8 +733,11 @@ async function connectH1 (client, socket) {
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
// to the user.
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so for as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
this[kError] = parserErr
this[kClient][kOnError](parserErr)
}
return
}
@@ -693,8 +756,10 @@ async function connectH1 (client, socket) {
const parser = this[kParser]
if (parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
util.destroy(this, parserErr)
}
return
}
@@ -704,10 +769,11 @@ async function connectH1 (client, socket) {
const client = this[kClient]
const parser = this[kParser]
clearIdleSocketValidation(this)
if (parser) {
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
this[kError] = parser.finish() || this[kError]
}
this[kParser].destroy()
@@ -770,7 +836,7 @@ async function connectH1 (client, socket) {
return socket.destroyed
},
busy (request) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking]) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
return true
}
@@ -808,6 +874,31 @@ async function connectH1 (client, socket) {
}
}
function clearIdleSocketValidation (socket) {
if (socket[kIdleSocketValidationTimeout]) {
clearTimeout(socket[kIdleSocketValidationTimeout])
socket[kIdleSocketValidationTimeout] = null
}
socket[kIdleSocketValidation] = 0
}
function scheduleIdleSocketValidation (client, socket) {
socket[kIdleSocketValidation] = 1
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
socket[kIdleSocketValidationTimeout] = null
socket[kIdleSocketValidation] = 2
if (client[kSocket] === socket && !socket.destroyed) {
client[kResume]()
}
}, 0)
socket[kIdleSocketValidationTimeout].unref?.()
}
/**
* @param {import('./client.js')} client
*/
function resumeH1 (client) {
const socket = client[kSocket]
@@ -822,6 +913,32 @@ function resumeH1 (client) {
socket[kNoRef] = false
}
if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
if (socket[kIdleSocketValidation] === 0) {
scheduleIdleSocketValidation(client, socket)
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
if (socket[kIdleSocketValidation] === 1) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
}
if (client[kRunning] === 0) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
}
if (client[kSize] === 0) {
if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@@ -877,8 +994,16 @@ function writeH1 (client, request) {
}
body = bodyStream.stream
contentLength = bodyStream.length
} else if (util.isBlobLike(body) && request.contentType == null && body.type) {
headers.push('content-type', body.type)
} else if (util.isBlobLike(body) && request.contentType == null) {
const contentType = body.type
if (contentType) {
const contentTypeValue = `${contentType}`
if (!util.isValidHeaderValue(contentTypeValue)) {
util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header'))
return false
}
headers.push('content-type', contentTypeValue)
}
}
if (body && typeof body.read === 'function') {
@@ -915,6 +1040,7 @@ function writeH1 (client, request) {
}
const socket = client[kSocket]
clearIdleSocketValidation(socket)
const abort = (err) => {
if (request.aborted || request.completed) {
+3 -2
View File
@@ -106,9 +106,10 @@ class Client extends DispatcherBase {
autoSelectFamilyAttemptTimeout,
// h2
maxConcurrentStreams,
allowH2
allowH2,
webSocket
} = {}) {
super()
super({ webSocket })
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
+10 -1
View File
@@ -11,15 +11,24 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = requ
const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kInterceptedDispatch = Symbol('Intercepted Dispatch')
const kWebSocketOptions = Symbol('webSocketOptions')
class DispatcherBase extends Dispatcher {
constructor () {
constructor (opts) {
super()
this[kDestroyed] = false
this[kOnDestroyed] = null
this[kClosed] = false
this[kOnClosed] = []
this[kWebSocketOptions] = opts?.webSocket ?? {}
}
get webSocketOptions () {
return {
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
}
}
get destroyed () {
+2 -2
View File
@@ -19,8 +19,8 @@ const kRemoveClient = Symbol('remove client')
const kStats = Symbol('stats')
class PoolBase extends DispatcherBase {
constructor () {
super()
constructor (opts) {
super(opts)
this[kQueue] = new FixedQueue()
this[kClients] = []
+2 -2
View File
@@ -37,8 +37,6 @@ class Pool extends PoolBase {
allowH2,
...options
} = {}) {
super()
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections')
}
@@ -63,6 +61,8 @@ class Pool extends PoolBase {
})
}
super(options)
this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
? options.interceptors.Pool
: []
+34
View File
@@ -15,6 +15,28 @@ function calculateRetryAfterHeader (retryAfter) {
return new Date(retryAfter).getTime() - current
}
function validatePartialResponseContentLength (headers, range, statusCode, retryCount) {
const contentLength = headers['content-length']
if (contentLength == null) {
return null
}
if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) {
return null
}
const length = Number(contentLength)
const expectedLength = range.end - range.start + 1
if (!Number.isFinite(length) || length !== expectedLength) {
return new RequestRetryError('Content-Length mismatch', statusCode, {
headers,
data: { count: retryCount }
})
}
return null
}
class RetryHandler {
constructor (opts, handlers) {
const { retryOptions, ...dispatchOpts } = opts
@@ -229,6 +251,12 @@ class RetryHandler {
return false
}
const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount)
if (contentLengthError != null) {
this.abort(contentLengthError)
return false
}
const { start, size, end = size - 1 } = contentRange
assert(this.start === start, 'content-range mismatch')
@@ -252,6 +280,12 @@ class RetryHandler {
)
}
const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount)
if (contentLengthError != null) {
this.abort(contentLengthError)
return false
}
const { start, size, end = size - 1 } = range
assert(
start != null && Number.isFinite(start),
+16 -23
View File
@@ -275,32 +275,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
// If the attribute-name case-insensitively matches the string
// "SameSite", the user agent MUST process the cookie-av as follows:
// 1. Let enforcement be "Default".
let enforcement = 'Default'
const attributeValueLowercase = attributeValue.toLowerCase()
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "None", set enforcement to "None".
if (attributeValueLowercase.includes('none')) {
enforcement = 'None'
}
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", set enforcement to "Strict".
if (attributeValueLowercase.includes('strict')) {
enforcement = 'Strict'
// 1. If cookie-av's attribute-value is a case-insensitive match for
// "None", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "None".
if (attributeValueLowercase === 'none') {
cookieAttributeList.sameSite = 'None'
} else if (attributeValueLowercase === 'strict') {
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", append an attribute to the cookie-attribute-list with
// an attribute-name of "SameSite" and an attribute-value of
// "Strict".
cookieAttributeList.sameSite = 'Strict'
} else if (attributeValueLowercase === 'lax') {
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "Lax".
cookieAttributeList.sameSite = 'Lax'
}
// 4. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", set enforcement to "Lax".
if (attributeValueLowercase.includes('lax')) {
enforcement = 'Lax'
}
// 5. Append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of
// enforcement.
cookieAttributeList.sameSite = enforcement
} else {
cookieAttributeList.unparsed ??= []
+79 -9
View File
@@ -105,7 +105,7 @@ function validateCookiePath (path) {
if (
code < 0x20 || // exclude CTLs (0-31)
code === 0x7F || // DEL
code > 0x7E || // exclude DEL and non-ascii
code === 0x3B // ;
) {
throw new Error('Invalid cookie path')
@@ -114,16 +114,80 @@ function validateCookiePath (path) {
}
/**
* I have no idea why these values aren't allowed to be honest,
* but Deno tests these. - Khafra
* <let-dig> ::= <letter> | <digit>
*
* <letter> ::= any one of the 52 alphabetic characters A through Z in
* upper case and a through z in lower case
*
* <digit> ::= any one of the ten digits 0 through 9r
*
* @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
* @param {number} code
*/
function isLetterOrDigit (code) {
return (
(code >= 0x30 && code <= 0x39) || // 0-9
(code >= 0x41 && code <= 0x5A) || // A-Z
(code >= 0x61 && code <= 0x7A) // a-z
)
}
/**
* Validates a cookie domain against the "preferred name syntax".
*
* <domain> ::= <subdomain> | " "
* <subdomain> ::= <label> | <subdomain> "." <label>
* <label> ::= <let-dig> [ [ <ldh-str> ] <let-dig> ]
* <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
* <let-dig-hyp> ::= <let-dig> | "-"
*
* @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
* @see https://www.rfc-editor.org/rfc/rfc1123#section-2.1
* @see https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4
* @param {string} domain
*/
function validateCookieDomain (domain) {
if (
domain.startsWith('-') ||
domain.endsWith('.') ||
domain.endsWith('-')
) {
// <domain> ::= <subdomain> | " "
if (domain === ' ') {
return
}
if (domain.length > 255) {
throw new Error('Invalid cookie domain')
}
let labelLength = 0
for (let i = 0; i < domain.length; ++i) {
const code = domain.charCodeAt(i)
if (code === 0x2E) {
if (labelLength === 0) {
throw new Error('Invalid cookie domain')
}
if (domain.charCodeAt(i - 1) === 0x2D) { // "-"
throw new Error('Invalid cookie domain')
}
labelLength = 0
continue
}
if (labelLength === 0 && !isLetterOrDigit(code)) {
throw new Error('Invalid cookie domain')
}
if (!isLetterOrDigit(code) && code !== 0x2D) { // "-"
throw new Error('Invalid cookie domain')
}
if (++labelLength > 63) {
throw new Error('Invalid cookie domain')
}
}
if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 0x2D) { // "-"
throw new Error('Invalid cookie domain')
}
}
@@ -266,7 +330,13 @@ function stringify (cookie) {
const [key, ...value] = part.split('=')
out.push(`${key.trim()}=${value.join('=')}`)
const trimmedKey = key.trim()
const joinedValue = value.join('=')
validateCookieName(trimmedKey)
validateCookieValue(joinedValue)
out.push(`${trimmedKey}=${joinedValue}`)
}
return out.join('; ')
+13 -36
View File
@@ -8,45 +8,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength')
// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */
#inflate
#options = {}
/** @type {number} */
#maxDecompressedSize
/** @type {boolean} */
#aborted = false
/** @type {Function|null} */
#currentCallback = null
#maxPayloadSize = 0
/**
* @param {Map<string, string>} extensions
* @param {{ maxDecompressedMessageSize?: number }} [options]
*/
constructor (extensions, options = {}) {
constructor (extensions, options) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize
this.#maxPayloadSize = options.maxPayloadSize
}
/**
* Decompress a compressed payload.
* @param {Buffer} chunk Compressed data
* @param {boolean} fin Final fragment flag
* @param {Function} callback Callback function
*/
decompress (chunk, fin, callback) {
// An endpoint uses the following algorithm to decompress a message.
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
// payload of the message.
// 2. Decompress the resulting data using DEFLATE.
if (this.#aborted) {
callback(new MessageSizeExceededError())
return
}
if (!this.#inflate) {
let windowBits = Z_DEFAULT_WINDOWBITS
@@ -69,23 +59,12 @@ class PerMessageDeflate {
this.#inflate[kLength] = 0
this.#inflate.on('data', (data) => {
if (this.#aborted) {
return
}
this.#inflate[kLength] += data.length
if (this.#inflate[kLength] > this.#maxDecompressedSize) {
this.#aborted = true
if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
callback(new MessageSizeExceededError())
this.#inflate.removeAllListeners()
this.#inflate.destroy()
this.#inflate = null
if (this.#currentCallback) {
const cb = this.#currentCallback
this.#currentCallback = null
cb(new MessageSizeExceededError())
}
return
}
@@ -98,14 +77,13 @@ class PerMessageDeflate {
})
}
this.#currentCallback = callback
this.#inflate.write(chunk)
if (fin) {
this.#inflate.write(tail)
}
this.#inflate.flush(() => {
if (this.#aborted || !this.#inflate) {
if (!this.#inflate) {
return
}
@@ -113,7 +91,6 @@ class PerMessageDeflate {
this.#inflate[kBuffer].length = 0
this.#inflate[kLength] = 0
this.#currentCallback = null
callback(null, full)
})
+106 -25
View File
@@ -18,6 +18,12 @@ const {
const { WebsocketFrameSend } = require('./frame')
const { closeWebSocketConnection } = require('./connection')
const { PerMessageDeflate } = require('./permessage-deflate')
const { MessageSizeExceededError } = require('../../core/errors')
function failWebsocketConnectionWithCode (ws, code, reason) {
closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason))
failWebsocketConnection(ws, reason)
}
// This code was influenced by ws released under the MIT license.
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -26,6 +32,7 @@ const { PerMessageDeflate } = require('./permessage-deflate')
class ByteParser extends Writable {
#buffers = []
#fragmentsBytes = 0
#byteOffset = 0
#loop = false
@@ -37,20 +44,24 @@ class ByteParser extends Writable {
/** @type {Map<string, PerMessageDeflate>} */
#extensions
/** @type {{ maxDecompressedMessageSize?: number }} */
#options
/** @type {number} */
#maxFragments
/** @type {number} */
#maxPayloadSize
/**
* @param {import('./websocket').WebSocket} ws
* @param {Map<string, string>|null} extensions
* @param {{ maxDecompressedMessageSize?: number }} [options]
* @param {{ maxFragments?: number, maxPayloadSize?: number }} [options]
*/
constructor (ws, extensions, options = {}) {
super()
this.ws = ws
this.#extensions = extensions == null ? new Map() : extensions
this.#options = options
this.#maxFragments = options.maxFragments ?? 0
this.#maxPayloadSize = options.maxPayloadSize ?? 0
if (this.#extensions.has('permessage-deflate')) {
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
@@ -69,6 +80,19 @@ class ByteParser extends Writable {
this.run(callback)
}
#validatePayloadLength () {
if (
this.#maxPayloadSize > 0 &&
!isControlFrame(this.#info.opcode) &&
this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize
) {
failWebsocketConnectionWithCode(this.ws, 1009, 'Payload size exceeds maximum allowed size')
return false
}
return true
}
/**
* Runs whenever a new chunk is received.
* Callback is called whenever there are no more chunks buffering,
@@ -157,6 +181,10 @@ class ByteParser extends Writable {
if (payloadLength <= 125) {
this.#info.payloadLength = payloadLength
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (payloadLength === 126) {
this.#state = parserStates.PAYLOADLENGTH_16
} else if (payloadLength === 127) {
@@ -181,6 +209,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = buffer.readUInt16BE(0)
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.PAYLOADLENGTH_64) {
if (this.#byteOffset < 8) {
return callback()
@@ -203,6 +235,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = lower
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.READ_DATA) {
if (this.#byteOffset < this.#info.payloadLength) {
return callback()
@@ -215,42 +251,58 @@ class ByteParser extends Writable {
this.#state = parserStates.INFO
} else {
if (!this.#info.compressed) {
this.#fragments.push(body)
if (!this.writeFragments(body)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
// If the frame is not fragmented, a message has been received.
// If the frame is fragmented, it will terminate with a fin bit set
// and an opcode of 0 (continuation), therefore we handle that when
// parsing continuation frames, not here.
if (!this.#info.fragmented && this.#info.fin) {
const fullMessage = Buffer.concat(this.#fragments)
websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
this.#fragments.length = 0
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
}
this.#state = parserStates.INFO
} else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
if (error) {
failWebsocketConnection(this.ws, error.message)
return
}
this.#extensions.get('permessage-deflate').decompress(
body,
this.#info.fin,
(error, data) => {
if (error) {
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnectionWithCode(this.ws, code, error.message)
return
}
this.#fragments.push(data)
if (!this.writeFragments(data)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.#state = parserStates.INFO
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
this.#loop = true
this.#state = parserStates.INFO
this.#fragments.length = 0
this.run(callback)
})
)
this.#loop = false
break
@@ -302,6 +354,35 @@ class ByteParser extends Writable {
return buffer
}
writeFragments (fragment) {
if (
this.#maxFragments > 0 &&
this.#fragments.length === this.#maxFragments
) {
failWebsocketConnectionWithCode(this.ws, 1008, 'Too many message fragments')
return false
}
this.#fragmentsBytes += fragment.length
this.#fragments.push(fragment)
return true
}
consumeFragments () {
const fragments = this.#fragments
if (fragments.length === 1) {
this.#fragmentsBytes = 0
return fragments.shift()
}
const output = Buffer.concat(fragments, this.#fragmentsBytes)
this.#fragments = []
this.#fragmentsBytes = 0
return output
}
parseCloseBody (data) {
assert(data.length !== 1)
+8 -22
View File
@@ -44,9 +44,6 @@ class WebSocket extends EventTarget {
/** @type {SendQueue} */
#sendQueue
/** @type {{ maxDecompressedMessageSize?: number }} */
#options
/**
* @param {string} url
* @param {string|string[]} protocols
@@ -120,11 +117,6 @@ class WebSocket extends EventTarget {
// 10. Set this's url to urlRecord.
this[kWebSocketURL] = new URL(urlRecord.href)
// Store options for later use (e.g., maxDecompressedMessageSize)
this.#options = {
maxDecompressedMessageSize: options.maxDecompressedMessageSize
}
// 11. Let client be this's relevant settings object.
const client = environmentSettingsObject.settingsObject
@@ -443,7 +435,14 @@ class WebSocket extends EventTarget {
// once this happens, the connection is open
this[kResponse] = response
const parser = new ByteParser(this, parsedExtensions, this.#options)
const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions
const maxFragments = webSocketOptions?.maxFragments
const maxPayloadSize = webSocketOptions?.maxPayloadSize
const parser = new ByteParser(this, parsedExtensions, {
maxFragments,
maxPayloadSize
})
parser.on('drain', onParserDrain)
parser.on('error', onParserError.bind(this))
@@ -546,19 +545,6 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([
{
key: 'headers',
converter: webidl.nullableConverter(webidl.converters.HeadersInit)
},
{
key: 'maxDecompressedMessageSize',
converter: webidl.nullableConverter((V) => {
V = webidl.converters['unsigned long long'](V)
if (V <= 0) {
throw webidl.errors.exception({
header: 'WebSocket constructor',
message: 'maxDecompressedMessageSize must be greater than 0'
})
}
return V
})
}
])
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "undici",
"version": "6.24.0",
"version": "6.28.0",
"description": "An HTTP/1.1 client, written from scratch for Node.js",
"homepage": "https://undici.nodejs.org",
"bugs": {
+17
View File
@@ -78,6 +78,8 @@ export declare namespace Client {
localAddress?: string;
/** Max response body size in bytes, -1 is disabled */
maxResponseSize?: number;
/** WebSocket-specific options */
webSocket?: Client.WebSocketOptions;
/** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
autoSelectFamily?: boolean;
/** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -103,6 +105,21 @@ export declare namespace Client {
bytesWritten?: number
bytesRead?: number
}
export interface WebSocketOptions {
/**
* Maximum number of fragments in a message.
* Set to 0 to disable the limit.
* @default 131072
*/
maxFragments?: number;
/**
* 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.
* @default 134217728 (128 MB)
*/
maxPayloadSize?: number;
}
}
export default Client;
+1 -8
View File
@@ -146,12 +146,5 @@ export declare const ErrorEvent: {
interface WebSocketInit {
protocols?: string | string[],
dispatcher?: Dispatcher,
headers?: HeadersInit,
/**
* Maximum size in bytes for decompressed WebSocket messages.
* When a message exceeds this limit during decompression, the connection
* will be closed with status code 1009 (Message Too Big).
* @default 4194304 (4 MB)
*/
maxDecompressedMessageSize?: number
headers?: HeadersInit
}