Node.js v24.22.1 fixes four HIGH CVEs, tested against v24.22.0 and EOL v25.3.0
Contents
TL;DR
What happened October 21, 2026: Node.js 26.x, 24.x, and 22.x got security releases fixing 10 CVEs, 4 rated HIGH (3 of them apply to 22.x). Two TLS hostname-verification bypasses, dns.resolve truncating names at a NUL character, and an out-of-bounds write in the Brotli decoder
What to do
- Run
node -v - Upgrade to v26.12.1, v24.22.1, or v22.23.4
- If you are on an end-of-life line such as v25 or v20, move to a supported line. No fix will ship for those
Also fixed Certificate chains through the compromised eMudhra intermediate CAs are now rejected with CERT_REVOKED
On October 21, 2026, Node.js shipped security fixes for the 26.x, 24.x, and 22.x release lines (a release line is the series for one major version) (Node.js — Wednesday, October 21, 2026 Security Releases).
There are 10 fixes: 4 HIGH, 3 MEDIUM, and 3 LOW.
The pre-announcement went out a week earlier, on October 14, and only said which lines were affected and that the highest severity was HIGH.
The M4 Mac mini (macOS 26.5.2) I write this blog on had Node.js v25.3.0 installed.
v25 reached end of life (EOL) on June 1, 2026, so it gets no fix this time (Node.js Release schedule).
I downloaded the pre-fix v24.22.0 and the patched v24.22.1 from nodejs.org to compare with my v25.3.0.
All three got the same inputs that the fix commits use in their tests.
The 10 fixes and the affected release lines
SEA in the table is Single Executable Applications, the feature that packs a Node.js app into one executable. The permission model is the mechanism that restricts file and network access when Node.js is started with --permission.
If you do not use the permission model, CVE-2026-106071 and 106073 do not affect you.
| CVE | Issue | Severity | Affected |
|---|---|---|---|
| CVE-2026-105482 | dns.resolve truncates names containing a NUL character | HIGH | 26.x, 24.x, 22.x |
| CVE-2026-105483 | TLS hostname verification bypass with an IP address written in fullwidth digits | HIGH | 26.x, 24.x |
| CVE-2026-106069 | TLS hostname verification bypass with a host that becomes an IP address after normalization | HIGH | 26.x, 24.x, 22.x |
| CVE-2026-111497 | Out-of-bounds write in the Brotli decoder, possible remote code execution | HIGH | 26.x, 24.x, 22.x |
| CVE-2026-105481 | A keep-alive socket processes a response written before the request was sent | MEDIUM | 26.x, 24.x, 22.x |
| CVE-2026-106072 | For non-canonical IPv4 hostnames, a certificate’s DNS name entry authenticates an IP endpoint | MEDIUM | 26.x, 24.x, 22.x |
| CVE-2026-106074 | Entropy caches for crypto.randomUUID() and randomInt() are saved into startup snapshots and SEAs, so every process repeats the same values | MEDIUM | 26.x, 24.x, 22.x |
| CVE-2026-106070 | ZipFile returns attacker-controlled prefix bytes after failed ZIP header validation | LOW | 26.x |
| CVE-2026-106071 | Permission model grants wider filesystem access than intended for wildcard entries | LOW | 26.x |
| CVE-2026-106073 | Permission model bypass with fs.realpathSync | LOW | 26.x, 24.x, 22.x |
The libraries bundled inside the Node.js binary were updated too (these are separate from what npm installs into node_modules). c-ares, the DNS resolver library, is now 1.34.9.
undici, the implementation behind fetch, is 8.12.2 on 26.x, 7.30.1 on 24.x, and 6.29.1 on 22.x.
OpenSSL went to 3.5.9 on 24.x and 22.x, and SQLite to 3.53.4 on 22.x (CHANGELOG_V24, CHANGELOG_V22, CHANGELOG_V26).
Alternate IPv4 spellings and TLS hostname verification (CVE-2026-105483, CVE-2026-106069)
On a TLS connection such as HTTPS, Node.js uses tls.checkServerIdentity() to check that the server’s certificate belongs to the host you are connecting to.
The check uses the certificate’s SAN (Subject Alternative Name, the list of hostnames and IP addresses the certificate is valid for). It has hostname entries like DNS:example.com and IP address entries like IP Address:127.0.0.1.
If the target is an IP address, it is compared with the IP Address: entries; if it is a hostname, with the DNS: entries.
IPv4 addresses can be written in more ways than 127.0.0.1, though: the short form 127.1, hex 0x7f.0.0.1, a single integer 2130706433, a leading zero 0127.0.0.1, fullwidth digits 127.0.0.1, or an ideographic full stop as the separator, 127。0.0.1.
Node.js’s net.isIP() does not treat any of these as an IP address.
On the other hand, url.domainToASCII(), which normalizes hostnames following the URL spec, turns them into IP addresses, and the OS resolver also resolves them to addresses such as 127.0.0.1.
Before the fix, Node.js compared the normalized string of such a host against the DNS: entries.
That means a certificate carrying an IP-shaped hostname like DNS:127.0.0.1 would pass verification for a connection to 127.1 and similar hosts.
The fix commit for the fullwidth-digit case (CVE-2026-105483) calls it a regression, meaning code that used to work broke after another change. It now compares fullwidth digits and similar spellings against the IP Address: entries (tls: fix ip normalization regression).
The same commit message also says the bug is not easy to exploit.
The fix for CVE-2026-106069 rejects every host that only becomes an IP address after normalization, before any matching happens (tls: reject non-canonical IP addresses).
According to the commit message, the reason is that platform resolvers can interpret the original host as a different address.
On this Mac, url.domainToASCII() reads the leading zero in 0127.0.0.1 as octal and returns 87.0.0.1, while dns.lookup() returns 127.0.0.1.
Pre-fix Node.js checks the certificate against 87.0.0.1, so a certificate with DNS:87.0.0.1 authenticates a connection that actually goes to 127.0.0.1.
For an attack to work, the attacker needs a certificate with an IP-shaped hostname in a DNS: entry, issued by a certificate authority (CA) the client trusts.
The fix commit’s test issues a DNS:87.0.0.1 certificate from a test CA, connects with rejectUnauthorized: true, and checks that the result is ERR_TLS_CERT_ALTNAME_INVALID.
Without connecting to any real server, I passed the host and SAN pairs from the fix commit’s tests straight into tls.checkServerIdentity() on all three versions. Only the pre-fix v24.22.0 accepted the certificate, in all three cases.
| Host | Certificate SAN | v24.22.0 (pre-fix) | v24.22.1 (patched) | v25.3.0 (EOL) |
|---|---|---|---|---|
127.0.0.1 (fullwidth digits) | DNS:127.0.0.1 | Accepted | Rejected | Rejected |
127.1 | DNS:127.0.0.1 | Accepted | Rejected | Rejected |
0127.0.0.1 | DNS:87.0.0.1 | Accepted | Rejected | Rejected |
The error message was Host is a non-canonical IP address on v24.22.1 and is not in the cert's altnames on v25.3.0.
Until you can upgrade, code that connects to hostnames taken from outside input can run the same check the patched version does.
If the original host is not an IP address according to net.isIP(), but becomes one after url.domainToASCII(), refuse to connect.
const net = require('node:net');
const url = require('node:url');
function isNonCanonicalIP(host) {
return !net.isIP(host) && net.isIP(url.domainToASCII(host)) !== 0;
}
On my machine this function returned true for the six spellings 127.1, 0127.0.0.1, 0x7f.0.0.1, 2130706433, fullwidth digits, and the ideographic full stop, and false for 127.0.0.1, ::1, and example.com.
dns.resolve and the NUL character (CVE-2026-105482)
The dns.resolve* functions such as dns.resolve4() resolve names through c-ares, a C library.
C strings treat the NUL character (\0, a byte with value 0) as the end marker, so if a JavaScript string contains a NUL in the middle, c-ares only receives the part before it.
In the fix commit’s test, resolving example.com\0.trusted.corp actually queries example.com (dns: validate resolve inputs for null bytes).
Even if an app only resolves names that end in .trusted.corp, an injected NUL means a different name gets queried than the one that passed the check.
The patched versions reject arguments containing NUL with ERR_INVALID_ARG_VALUE in dns.resolve*, dns.reverse(), and their Promise versions.
Addresses passed to setLocalAddress() on a Resolver are checked the same way.
With the same input on all three versions, only the patched one threw; v24.22.0 and v25.3.0 resolved example.com.
| Version | dns.resolve4('example.com\0.trusted.corp') |
|---|---|
| v24.22.0 (pre-fix) | Returned two IP addresses for example.com |
| v24.22.1 (patched) | Threw ERR_INVALID_ARG_VALUE |
| v25.3.0 (EOL) | Returned two IP addresses for example.com |
If you cannot upgrade yet, checking name.includes('\0') before passing outside input to dns.resolve* blocks it the same way the patched version does.
Out-of-bounds write in the Brotli decoder (CVE-2026-111497)
Brotli is a compression format common in HTTP responses, and Node.js bundles Google’s Brotli library in its zlib module.
The fix is a cherry-pick of a commit from that library (deps: brotli: cherry-pick 0ed4268a4401abcb44720963).
The advisory title says the out-of-bounds write permits remote code execution, and gives no further detail.
The cherry-picked commit makes the decoder fail immediately on invalid data when a block switch appears even though the stream declares only one block type.
Node.js also gained a constant for that error, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (src: expose BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH).
You hit this decoder through fetch() even if you never call zlib.brotliDecompress() yourself.
Node.js’s fetch() automatically decompresses a response with zlib.createBrotliDecompress() when its Content-Encoding header contains br. In the undici 7.30.1 source shipped with v24.22.1, whether to decompress depends only on the response header.
Sending Accept-Encoding: identity in the request does not help: a malicious server can still reply with Content-Encoding: br and the decoder runs.
Passing the invalid Brotli data from the fix commit’s test into zlib.brotliDecompressSync() produced an error on all three versions, but not the same error.
| Version | Decompressing the invalid Brotli data |
|---|---|
| v24.22.0 (pre-fix) | ERR__ERROR_FORMAT_BLOCK_LENGTH_1 |
| v24.22.1 (patched) | ERR__ERROR_FORMAT_BLOCK_SWITCH |
| v25.3.0 (EOL) | ERR__ERROR_FORMAT_BLOCK_LENGTH_1 |
The pre-fix versions also errored on this test data, so this test alone cannot tell whether the out-of-bounds write happens.
Blocking chains through the compromised eMudhra intermediate CAs
In eMudhra’s intermediate CA private keys had only 255 possible values, fetch on my Node.js v25.3.0 connected without complaint through intermediate CAs whose private keys anyone could compute (that is, compromised CAs).
This release adds a check that rejects a certificate chain when it contains specific public keys (tls: reject chains containing blocked public keys).
The commit message explains that Node.js embeds the Mozilla root store but has no equivalent of Chrome’s CRLSet or Firefox’s OneCRL blocklists, and no online revocation checking by default.
Removing the affected roots would also break legitimate certificates chaining to them, so Node.js now compiles in SHA-256 hashes of the blocked public keys and reports a match as CERT_REVOKED.
The initial entries are the eight eMudhra/emSign intermediates, and the commit says Go and Electron ship the same list.
The bundled root store was also updated to match Mozilla’s NSS 3.131.1.
The test site from the previous post has one host that serves a certificate signed by a compromised intermediate through a chain to the eMudhra root, and another through a chain to a Sectigo root.
Connecting to both with fetch, only the patched version rejected both with CERT_REVOKED. These results are from 21:18 JST on October 21, 2026.
| Version | Via eMudhra root | Via Sectigo root |
|---|---|---|
| v24.22.0 (pre-fix) | Connected (200) | UNABLE_TO_GET_ISSUER_CERT_LOCALLY |
| v24.22.1 (patched) | CERT_REVOKED | CERT_REVOKED |
| v25.3.0 (EOL) | Connected (200) | Connected (200) |
Checking the bundled roots with tls.rootCertificates, the Sectigo root AAA Certificate Services was missing on all three versions. That list does not explain why v24.22.0 failed via Sectigo while v25.3.0 connected.
The eMudhra root emSign ECC Root CA - G3 was present on v24.22.0 and v25.3.0, and removed on v24.22.1.
How to upgrade
The fixed versions are v26.12.1, v24.22.1, and v22.23.4.
Check your version with node -v. If you are on an EOL line such as v25, v23, or v20, no fix will come, so move to a supported line: v26, v24, or v22.
My v25.3.0 did reject the three TLS cases, but it let the DNS and eMudhra cases through just like the pre-fix v24.22.0.
On the LTS (long-term support) schedule, v24 entered maintenance LTS on October 20, 2026, and v26 is due to become active LTS on October 28.
| Install method | How to update | Status on release day (October 21) |
|---|---|---|
| nodejs.org downloads | Reinstall from the download page | All three published |
| nvm (Node.js version manager) | nvm install 24 installs the latest 24.x | Pulls the published downloads |
| Docker | Pin the version, e.g. FROM node:24.22.1, and rebuild the image | Tags node:24.22.1, node:22.23.4, and node:26.12.1 were pushed to Docker Hub at 20:39 JST |
| Homebrew | brew upgrade node (same for node@24, node@22) | Still at node 26.12.0, node@24 24.22.0, node@22 22.23.3 around 21:20 JST |
The v24.22.1 used for the tests in this post came from the nodejs.org download, verified against SHASUMS256.txt before extracting.
curl -LO https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz
curl -L https://nodejs.org/dist/v24.22.1/SHASUMS256.txt | grep darwin-arm64.tar.gz | shasum -a 256 -c
tar xzf node-v24.22.1-darwin-arm64.tar.gz
./node-v24.22.1-darwin-arm64/bin/node -v
With Docker, even a floating tag like node:24 stays on the old version if you rebuild from a cached image, so run docker pull first and then rebuild.
Homebrew was still on the previous versions as of around 21:20 JST, so if you are in a hurry, the nodejs.org download or nvm is faster.