HTTP header token validation / input validation
Description
The commit introduces stricter HTTP token validation in undici (token character whitelist, updated isValidHTTPToken, and related utility changes). This hardens header-token handling and input validation to reduce the risk of malformed header values or token-based injection in HTTP requests. In particular, it adds a validated token character map, replaces per-character checks with a mixed per-char/regex approach, and tightens what is considered a valid HTTP token. These changes address potential weaknesses where previously permissive token parsing could allow anomalous tokens to bypass validation, potentially enabling header injection or related attacks via crafted header tokens. The upgrade to undici 7.18.2 includes this hardening alongside other stability fixes.
Commit Details
Author: Node.js GitHub Bot
Date: 2026-01-07 07:47 UTC
Message:
deps: update undici to 7.18.2
PR-URL: https://github.com/nodejs/node/pull/61283
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Richard Lau <richard.lau@ibm.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: Matthew Aitken <maitken033380023@gmail.com>
Triage Assessment
Vulnerability Type: Input validation / HTTP header handling
Confidence: MEDIUM
Reasoning:
The changes include hardened HTTP token validation and input handling in undici (e.g., stricter token character checks and token validation logic in util.js, plus related HTTP header processing adjustments). These are typical security-hardening fixes to prevent malformed header values or token-based injection. Other changes are broader undici upgrades and stability fixes, but the token validation updates indicate a security-focused improvement.
Verification Assessment
Vulnerability Type: HTTP header token validation / input validation
Confidence: MEDIUM
Affected Versions: undici < 7.18.2 (i.e., 7.0.0 through 7.18.1)
Code Diff
diff --git a/deps/undici/src/eslint.config.js b/deps/undici/src/eslint.config.js
index 0669f4b47d7c2a..d768afe6b9bae4 100644
--- a/deps/undici/src/eslint.config.js
+++ b/deps/undici/src/eslint.config.js
@@ -16,6 +16,8 @@ module.exports = [
}),
{
rules: {
+ 'n/prefer-node-protocol': ['error'],
+ 'n/no-process-exit': 'error',
'@stylistic/comma-dangle': ['error', {
arrays: 'never',
objects: 'never',
diff --git a/deps/undici/src/index.js b/deps/undici/src/index.js
index 9e31134f743fc0..14f439a2334707 100644
--- a/deps/undici/src/index.js
+++ b/deps/undici/src/index.js
@@ -4,6 +4,7 @@ const Client = require('./lib/dispatcher/client')
const Dispatcher = require('./lib/dispatcher/dispatcher')
const Pool = require('./lib/dispatcher/pool')
const BalancedPool = require('./lib/dispatcher/balanced-pool')
+const RoundRobinPool = require('./lib/dispatcher/round-robin-pool')
const Agent = require('./lib/dispatcher/agent')
const ProxyAgent = require('./lib/dispatcher/proxy-agent')
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
@@ -31,6 +32,7 @@ module.exports.Dispatcher = Dispatcher
module.exports.Client = Client
module.exports.Pool = Pool
module.exports.BalancedPool = BalancedPool
+module.exports.RoundRobinPool = RoundRobinPool
module.exports.Agent = Agent
module.exports.ProxyAgent = ProxyAgent
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent
@@ -47,7 +49,8 @@ module.exports.interceptors = {
dump: require('./lib/interceptor/dump'),
dns: require('./lib/interceptor/dns'),
cache: require('./lib/interceptor/cache'),
- decompress: require('./lib/interceptor/decompress')
+ decompress: require('./lib/interceptor/decompress'),
+ deduplicate: require('./lib/interceptor/deduplicate')
}
module.exports.cacheStores = {
diff --git a/deps/undici/src/lib/api/api-upgrade.js b/deps/undici/src/lib/api/api-upgrade.js
index f6efdc98626515..2b03f2075628b0 100644
--- a/deps/undici/src/lib/api/api-upgrade.js
+++ b/deps/undici/src/lib/api/api-upgrade.js
@@ -4,6 +4,7 @@ const { InvalidArgumentError, SocketError } = require('../core/errors')
const { AsyncResource } = require('node:async_hooks')
const assert = require('node:assert')
const util = require('../core/util')
+const { kHTTP2Stream } = require('../core/symbols')
const { addSignal, removeSignal } = require('./abort-signal')
class UpgradeHandler extends AsyncResource {
@@ -50,7 +51,7 @@ class UpgradeHandler extends AsyncResource {
}
onUpgrade (statusCode, rawHeaders, socket) {
- assert(statusCode === 101)
+ assert(socket[kHTTP2Stream] === true ? statusCode === 200 : statusCode === 101)
const { callback, opaque, context } = this
diff --git a/deps/undici/src/lib/core/connect.js b/deps/undici/src/lib/core/connect.js
index 4e11deee37feae..a49af91486e623 100644
--- a/deps/undici/src/lib/core/connect.js
+++ b/deps/undici/src/lib/core/connect.js
@@ -43,7 +43,7 @@ const SessionCache = class WeakSessionCache {
}
}
-function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
+function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
}
@@ -96,6 +96,9 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
port,
host: hostname
})
+ if (useH2c === true) {
+ socket.alpnProtocol = 'h2'
+ }
}
// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
diff --git a/deps/undici/src/lib/core/diagnostics.js b/deps/undici/src/lib/core/diagnostics.js
index 224a5c49f5da2c..ccd6870ca6d9f1 100644
--- a/deps/undici/src/lib/core/diagnostics.js
+++ b/deps/undici/src/lib/core/diagnostics.js
@@ -26,7 +26,9 @@ const channels = {
close: diagnosticsChannel.channel('undici:websocket:close'),
socketError: diagnosticsChannel.channel('undici:websocket:socket_error'),
ping: diagnosticsChannel.channel('undici:websocket:ping'),
- pong: diagnosticsChannel.channel('undici:websocket:pong')
+ pong: diagnosticsChannel.channel('undici:websocket:pong'),
+ // ProxyAgent
+ proxyConnected: diagnosticsChannel.channel('undici:proxy:connected')
}
let isTrackingClientEvents = false
@@ -36,6 +38,14 @@ function trackClientEvents (debugLog = undiciDebugLog) {
return
}
+ // Check if any of the channels already have subscribers to prevent duplicate subscriptions
+ // This can happen when both Node.js built-in undici and undici as a dependency are present
+ if (channels.beforeConnect.hasSubscribers || channels.connected.hasSubscribers ||
+ channels.connectError.hasSubscribers || channels.sendHeaders.hasSubscribers) {
+ isTrackingClientEvents = true
+ return
+ }
+
isTrackingClientEvents = true
diagnosticsChannel.subscribe('undici:client:beforeConnect',
@@ -98,6 +108,14 @@ function trackRequestEvents (debugLog = undiciDebugLog) {
return
}
+ // Check if any of the channels already have subscribers to prevent duplicate subscriptions
+ // This can happen when both Node.js built-in undici and undici as a dependency are present
+ if (channels.headers.hasSubscribers || channels.trailers.hasSubscribers ||
+ channels.error.hasSubscribers) {
+ isTrackingRequestEvents = true
+ return
+ }
+
isTrackingRequestEvents = true
diagnosticsChannel.subscribe('undici:request:headers',
@@ -146,6 +164,15 @@ function trackWebSocketEvents (debugLog = websocketDebuglog) {
return
}
+ // Check if any of the channels already have subscribers to prevent duplicate subscriptions
+ // This can happen when both Node.js built-in undici and undici as a dependency are present
+ if (channels.open.hasSubscribers || channels.close.hasSubscribers ||
+ channels.socketError.hasSubscribers || channels.ping.hasSubscribers ||
+ channels.pong.hasSubscribers) {
+ isTrackingWebSocketEvents = true
+ return
+ }
+
isTrackingWebSocketEvents = true
diagnosticsChannel.subscribe('undici:websocket:open',
diff --git a/deps/undici/src/lib/core/symbols.js b/deps/undici/src/lib/core/symbols.js
index f3b563a5419b97..00c4f8ff23beb8 100644
--- a/deps/undici/src/lib/core/symbols.js
+++ b/deps/undici/src/lib/core/symbols.js
@@ -62,6 +62,9 @@ module.exports = {
kListeners: Symbol('listeners'),
kHTTPContext: Symbol('http context'),
kMaxConcurrentStreams: Symbol('max concurrent streams'),
+ kEnableConnectProtocol: Symbol('http2session connect protocol'),
+ kRemoteSettings: Symbol('http2session remote settings'),
+ kHTTP2Stream: Symbol('http2session client stream'),
kNoProxyAgent: Symbol('no proxy agent'),
kHttpProxyAgent: Symbol('http proxy agent'),
kHttpsProxyAgent: Symbol('https proxy agent')
diff --git a/deps/undici/src/lib/core/util.js b/deps/undici/src/lib/core/util.js
index d8833c01e7c92c..fd5b0dfaafbc15 100644
--- a/deps/undici/src/lib/core/util.js
+++ b/deps/undici/src/lib/core/util.js
@@ -615,14 +615,14 @@ function ReadableStreamFrom (iterable) {
pull (controller) {
return iterator.next().then(({ done, value }) => {
if (done) {
- queueMicrotask(() => {
+ return queueMicrotask(() => {
controller.close()
controller.byobRequest?.respond(0)
})
} else {
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
if (buf.byteLength) {
- controller.enqueue(new Uint8Array(buf))
+ return controller.enqueue(new Uint8Array(buf))
} else {
return this.pull(controller)
}
@@ -666,48 +666,46 @@ function addAbortListener (signal, listener) {
return () => signal.removeListener('abort', listener)
}
+const validTokenChars = new Uint8Array([
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
+ 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32-47 (!"#$%&'()*+,-./)
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48-63 (0-9:;<=>?)
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64-79 (@A-O)
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80-95 (P-Z[\]^_)
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96-111 (`a-o)
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, // 112-127 (p-z{|}~)
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 128-143
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 144-159
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 160-175
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 176-191
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 192-207
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 208-223
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 224-239
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 240-255
+])
+
/**
* @see https://tools.ietf.org/html/rfc7230#section-3.2.6
* @param {number} c
* @returns {boolean}
*/
function isTokenCharCode (c) {
- switch (c) {
- case 0x22:
- case 0x28:
- case 0x29:
- case 0x2c:
- case 0x2f:
- case 0x3a:
- case 0x3b:
- case 0x3c:
- case 0x3d:
- case 0x3e:
- case 0x3f:
- case 0x40:
- case 0x5b:
- case 0x5c:
- case 0x5d:
- case 0x7b:
- case 0x7d:
- // DQUOTE and "(),/:;<=>?@[\]{}"
- return false
- default:
- // VCHAR %x21-7E
- return c >= 0x21 && c <= 0x7e
- }
+ return (validTokenChars[c] === 1)
}
+const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/
+
/**
* @param {string} characters
* @returns {boolean}
*/
function isValidHTTPToken (characters) {
- if (characters.length === 0) {
- return false
- }
- for (let i = 0; i < characters.length; ++i) {
- if (!isTokenCharCode(characters.charCodeAt(i))) {
+ if (characters.length >= 12) return tokenRegExp.test(characters)
+ if (characters.length === 0) return false
+
+ for (let i = 0; i < characters.length; i++) {
+ if (validTokenChars[characters.charCodeAt(i)] !== 1) {
return false
}
}
diff --git a/deps/undici/src/lib/dispatcher/balanced-pool.js b/deps/undici/src/lib/dispatcher/balanced-pool.js
index 5bbec0e618dbb5..d53a26966adbf2 100644
--- a/deps/undici/src/lib/dispatcher/balanced-pool.js
+++ b/deps/undici/src/lib/dispatcher/balanced-pool.js
@@ -140,6 +140,16 @@ class BalancedPool extends PoolBase {
return this
}
+ getUpstream (upstream) {
+ const upstreamOrigin = parseOrigin(upstream).origin
+
+ return this[kClients].find((pool) => (
+ pool[kUrl].origin === upstreamOrigin &&
+ pool.closed !== true &&
+ pool.destroyed !== true
+ ))
+ }
+
get upstreams () {
return this[kClients]
.filter(dispatcher => dispatcher.closed !== true && dispatcher.destroyed !== true)
diff --git a/deps/undici/src/lib/dispatcher/client-h1.js b/deps/undici/src/lib/dispatcher/client-h1.js
index c775cb988f4786..09d1a7599c4e04 100644
--- a/deps/undici/src/lib/dispatcher/client-h1.js
+++ b/deps/undici/src/lib/dispatcher/client-h1.js
@@ -77,12 +77,10 @@ function lazyllhttp () {
if (useWasmSIMD) {
try {
mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
- /* istanbul ignore next */
} catch {
}
}
- /* istanbul ignore next */
if (!mod) {
// We could check if the error was caused by the simd option not
// being enabled, but the occurring of this other error
@@ -100,7 +98,6 @@ function lazyllhttp () {
* @returns {number}
*/
wasm_on_url: (p, at, len) => {
- /* istanbul ignore next */
return 0
},
/**
@@ -265,7 +262,6 @@ class Parser {
this.timeoutValue = delay
} else if (this.timeout) {
- // istanbul ignore else: only for jest
if (this.timeout.refresh) {
this.timeout.refresh()
}
@@ -286,7 +282,6 @@ class Parser {
assert(this.timeoutType === TIMEOUT_BODY)
if (this.timeout) {
- // istanbul ignore else: only for jest
if (this.timeout.refresh) {
this.timeout.refresh()
}
@@ -356,7 +351,6 @@ class Parser {
} else {
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 =
@@ -402,7 +396,6 @@ class Parser {
onMessageBegin () {
const { socket, client } = this
- /* istanbul ignore next: difficult to make a test case for */
if (socket.destroyed) {
return -1
}
@@ -531,14 +524,12 @@ class Parser {
onHeadersComplete (statusCode, upgrade, shouldKeepAlive) {
const { client, socket, headers, statusText } = this
- /* istanbul ignore next: difficult to make a test case for */
if (socket.destroyed) {
return -1
}
const request = client[kQueue][client[kRunningIdx]]
- /* istanbul ignore next: difficult to make a test case for */
if (!request) {
return -1
}
@@ -572,7 +563,6 @@ class Parser {
: client[kBodyTimeout]
this.setTimeout(bodyTimeout, TIMEOUT_BODY)
} else if (this.timeout) {
- // istanbul ignore else: only for jest
if (this.timeout.refresh) {
this.timeout.refresh()
}
@@ -653,7 +643,6 @@ class Parser {
assert(this.timeoutType === TIMEOUT_BODY)
if (this.timeout) {
- // istanbul ignore else: only for jest
if (this.timeout.refresh) {
this.timeout.refresh()
}
@@ -709,7 +698,6 @@ class Parser {
return 0
}
- /* istanbul ignore next: should be handled by llhttp? */
if (request.method !== 'HEAD' && contentLength && bytesRead !== parseInt(contentLength, 10)) {
util.destroy(socket, new ResponseContentLengthMismatchError())
return -1
@@ -750,7 +738,6 @@ class Parser {
function onParserTimeout (parser) {
const { socket, timeoutType, client, paused } = parser.deref()
- /* istanbul ignore else */
if (timeoutType === TIMEOUT_HEADERS) {
if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) {
assert(!paused, 'cannot be paused while waiting for headers')
@@ -1157,7 +1144,6 @@ function writeH1 (client, request) {
channels.sendHeaders.publish({ request, headers: header, socket })
}
- /* istanbul ignore else: assertion */
if (!body || bodyLength === 0) {
writeBuffer(abort, null, client, request, socket, contentLength, header, expectsPayload)
} else if (util.isBuffer(body)) {
@@ -1538,7 +1524,6 @@ class AsyncWriter {
if (!ret) {
if (socket[kParser].timeout
... [truncated]