h3llh0und

CVE-2026-109383: three of the four names on the list were right

In September undici fixed a dispatcher that silently dropped an application's certificate-pinning callback, and the advisory named four other dispatchers as not affected. I checked the four names one at a time. Agent, the default dispatcher behind fetch, and the one the advisory's workaround recommended, had the same defect. The fix is six lines, and two releases shipped while it waited.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every connection in it ran between a client and an HTTPS server inside a throwaway container on my own machine, with a certificate authority I generated for the purpose. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed releases and the CVE record were public.

On 4 September undici, the HTTP client that Node.js ships as its fetch, published GHSA-w293-vg96-wgc3, CVE-2026-84961. BalancedPool had been losing an application’s checkServerIdentity callback on the way to the connection, so a certificate the application had pinned against was accepted anyway. The advisory explained the mechanism, gave the fixed versions, and then said this:

“Client, Pool, Agent, and RoundRobinPool destructure connect/tls before the clone and are not affected.”

That sentence makes four claims. It is also the sentence a reader relies on to decide whether to keep reading, and the advisory’s Workarounds section built on it: “Use Client, Pool, or Agent instead of BalancedPool … until upgraded.” So I checked the four names.

What “destructure before the clone” means

Each undici dispatcher takes an options object and passes most of it down to the connections it creates. Several of them copy that object first, through a helper in lib/core/util.js:

function deepClone (obj) {
  return JSON.parse(JSON.stringify(obj))
}

JSON has no way to represent a function. A string survives this round trip; a callback is dropped without an error. That is a problem for the TLS options, because the interesting ones are callbacks. checkServerIdentity is how an application says “this certificate chains to a trusted root and matches the hostname, but it is still not the one I expect”. Drop it, and Node falls back to its default check, which the attacker’s certificate was already built to pass.

“Destructure before the clone” means pulling connect and tls out of the options object by name, so that they go around deepClone instead of through it. The September fix did exactly that for BalancedPool.

Four names

For Client, Pool and RoundRobinPool the sentence holds: the probe below shows each of them keeping the callback.

Agent’s constructor reads:

// lib/dispatcher/agent.js, 8.10.2
constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
  // ...
  if (connect && typeof connect !== 'function') {
    connect = { ...connect }
  }

  this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }

connect is named in the destructure, shallow-copied, and kept out of the clone. tls is not named, so it stays inside ...options and goes through deepClone with everything else. Whoever wrote that constructor knew the clone was dangerous for callbacks, because they protected connect from it. They protected one of the two option names that can carry a callback.

Agent matters more than the other three. It is the dispatcher behind request(), behind fetch(), and behind setGlobalDispatcher(new Agent(...)), which is how an application changes TLS settings for all of its outbound calls. And the Workarounds section had sent BalancedPool users to it.

A probe that can tell the two states apart

A probe that reports “bypass” proves nothing until it has also reported “enforced” on code known to be fixed. Mine was a container with an openssl-generated CA, a localhost leaf certificate, and an HTTPS server that answers SECRET-BODY. Every dispatcher received the same options, tls: { ca, checkServerIdentity: () => new Error('PIN MISMATCH') }. The CA makes the chain valid, so the pin is the only thing that can refuse the connection. BYPASS means the request completed and the body was read.

The control was CVE-2026-84961 itself, measured on both sides of its own fix:

undici 8.10.1   BalancedPool   BYPASS     <- the September bug, detected
undici 8.10.2   BalancedPool   ENFORCED   <- the September fix, detected

The probe sees the difference, so its other rows are measurements. On 8.10.2, the current release when I reported:

Client          ENFORCED
Pool            ENFORCED
RoundRobinPool  ENFORCED
BalancedPool    ENFORCED
Agent           BYPASS       HTTP 200 body=SECRET-BODY
Agent(connect)  ENFORCED     the same callback, passed as connect instead of tls

I ran it back through every supported major line, through Agent.request and through fetch with a global dispatcher:

undici 5.29.0   Agent.request=BYPASS   fetch/global=BYPASS
undici 6.28.1   Agent.request=BYPASS   fetch/global=BYPASS
undici 7.29.1   Agent.request=BYPASS   fetch/global=BYPASS
undici 8.10.2   Agent.request=BYPASS   fetch/global=BYPASS

The report also had a third column, agent[kOptions].tls keys=["ca"], which it described as the mechanism caught in the act: the caller passed { ca, checkServerIdentity } and only ca survived. Re-reading the probe for this post, I found that the line producing that column built its Agent with { ca } alone. It could never have printed anything else, so it measured nothing. The BYPASS results never depended on it, but the sentence in the report was wrong. The measurement it claimed to be is in the last section of this post, taken properly this time.

One variation fails closed. A ca passed as a Buffer comes out of the JSON round trip as {"type":"Buffer","data":[...]}, and Node throws ERR_INVALID_ARG_TYPE on the first connection. That application breaks loudly and accepts nothing, so I reported it as a negative result.

The type says never

There was an obvious objection, and I put it in the report before anyone could raise it. undici’s TypeScript definitions type tls on Client.Options as never, with @deprecated use the connect option instead. A TypeScript caller is steered away from the very option this is about.

I still thought it was a defect, and said why. The runtime honours tls on four of the five dispatchers. The option is documented for Pool, which Agent.Options extends. The September fix had chosen to preserve tls across the clone rather than reject it, which shows how undici treats the option at runtime. A deprecated option that works everywhere except one place, and fails there by turning off the check the caller asked for, is not something a reader can be expected to notice from a doc comment. My report offered two acceptable outcomes: keep tls out of the clone, or throw InvalidArgumentError when it is passed. The only unacceptable one was to continue silently.

Matteo Collina accepted the report on 14 September, six days after I sent it, and opened a private fork. On 12 October Ulises Gascón reserved CVE-2026-109383 through the OpenJS Foundation’s CNA. He set the affected ranges to < 6.29.1, < 7.30.1 and < 8.11.3, the next patch release after the one then current.

Two releases in the gap

On 14 October undici released 8.12.0 at 09:03 UTC and 8.12.1 twenty minutes later. Neither contained the fix. I read the constructor on the 8.12.1 tag and found tls still missing from the destructure. I then rebuilt the original probe, unchanged, against 8.11.2 and 8.12.1: BYPASS on both, with the Agent({connect}) control ENFORCED on both.

That release created a problem with the advisory as drafted. Its 8.x range ended at < 8.11.3, so once published it would have told anyone on 8.12.0 or 8.12.1 that they were not affected. I wrote it down that day as something to raise in the thread.

I didn’t need to. The fix commit, f75eac0, “fix: preserve Agent connection options”, is dated 14 October at 10:09 UTC, forty-five minutes after 8.12.1 was tagged. The next morning, before I had written anything in the thread, Ulises posted “Updated to 8.12.2 👍” and moved the range to >= 8.0.0, < 8.12.2.

Six lines

-  constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
+  constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, tls, ...options } = {}) {
@@
-    this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
+    if (tls && typeof tls !== 'function') {
+      tls = { ...tls }
+    }
+
+    this[kOptions] = { ...util.deepClone(options), maxOrigins, connect, tls }

tls now gets exactly the treatment connect already had: named in the destructure, shallow-copied, kept out of the clone. That is the change my report proposed, plus the shallow copy, which makes it consistent with the line above it. It comes with 98 lines of tests.

7.30.1, 6.29.1 and 8.12.2 reached npm on the afternoon of 15 October, and the advisory was published at 15:59 UTC, one of eight undici advisories published within the same few minutes. That evening I re-ran the original probe against the last affected and first fixed release of each line:

  6.29.0 6.29.1 7.30.0 7.30.1 8.12.1 8.12.2
Agent.request with tls callback BYPASS ENFORCED BYPASS ENFORCED BYPASS ENFORCED
fetch through a global Agent BYPASS ENFORCED BYPASS ENFORCED BYPASS ENFORCED
control, Agent({ connect }) ENFORCED ENFORCED ENFORCED ENFORCED ENFORCED ENFORCED

Then the measurement the report’s third column should have been. The same two option objects go into new Agent({ tls }), and the keys of what the agent stores for its per-origin pools are listed:

undici 6.29.0   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca"]
undici 6.29.1   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca","checkServerIdentity"]
undici 7.30.0   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca"]
undici 7.30.1   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca","checkServerIdentity"]
undici 8.12.1   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca"]
undici 8.12.2   passed {ca}: ["ca"]   passed {ca, checkServerIdentity}: ["ca","checkServerIdentity"]

This time the fixed releases are the control. They show the probe can see the callback when it is there, so its absence on the affected releases is a reading rather than a blind spot.

On the record

The advisory carries CVSS 3.1 7.4 High, AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N. That is the vector I proposed, and the same one undici assigned to both earlier instances of this bug, CVE-2026-84961 in BalancedPool and CVE-2026-9697 in the SOCKS5 ProxyAgent. AC:H keeps it below critical, because an attacker needs a network position and a certificate that passes Node’s default validation for the target name. The weakness is CWE-295, improper certificate validation.

Three people are credited as reporters: me, BlueN0r and ksanjeev284. I don’t know when the other two reports arrived or what they contained. The credits on the advisory are the only record of them I can see. The OpenJS CNA copied the advisory’s credits into the CVE record, as it did for my multer report, so all three of us are listed there too.

As for the sentence I started from, the new advisory has no list of dispatchers that are safe. It has a list of ones that are not. Besides Agent, it names EnvHttpProxyAgent, “on its direct and NO_PROXY routes, which delegate to an internal Agent“. My report never mentioned that dispatcher. The new workaround advises passing the TLS options through connect, the one route the probe found safe on every version I tested.


Advisory GHSA-2m5v-pch6-74hx · CVE CVE-2026-109383 · Fix f75eac0 · Affected >= 4.0.0, < 6.29.1, >= 7.0.0, < 7.30.1, >= 8.0.0, < 8.12.2

Reported 8 September · accepted 14 September · CVE reserved 12 October · 8.12.0 and 8.12.1 released 14 October without the fix · range moved to < 8.12.2 on 15 October · fixed releases and advisory published 15 October, crediting three reporters · CVE record published by the OpenJS CNA the same day.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und