Node2Shell (CVE-2026-111497): Brotli Decoder Memory Corruption in Node.js 22 / 24 / 26, Check and Vaccinate
npm audit scans your dependency tree. The vulnerable code is in the Node binary's native Brotli decoder, reachable on both inbound requests and outbound fetch().
01. Observed Symptoms & Why Standard Package Audits Fail
Observed (identifiers): CVE-2026-111497 (Node2Shell), CVE-2026-105482 and CVE-2026-106069 are recorded in HadayaLab's intake from the Node.js security releases at https://nodejs.org/en/blog/vulnerability/. The defect is described as an out-of-bounds write in the native C++ Brotli decoder. Fixed lines: v22.23.4+, v24.22.1+, v26.12.1+. Re-read the advisory before acting.
Symptoms that call for a runtime check: (1) npm audit is clean but the advisory names Node itself. (2) A NestJS or Express service crashes with SIGSEGV or aborts after receiving unusual requests or after calling a third-party URL. (3) A container labelled node:22-alpine was built weeks ago and nobody can say which patch level it holds. (4) Workloads that fetch arbitrary URLs (webhook senders, link previews, AI agents, OAuth discovery) run on a pre-fix Node.
Observed (tooling behaviour): npm audit evaluates package-lock.json against an advisory feed. The Brotli decoder ships inside the Node executable, so no entry in your lockfile will ever flag it.
Inference (Node 20): The Node.js release schedule lists v20 as end-of-life since 2026-04-30. The fixed lines supplied for this advisory are 22, 24 and 26 only. Unless the advisory states otherwise, assume Node 20 will not receive a fix and plan a migration, not a wait.
02. Root-Cause Forensic Analysis (Runtime & Protocol Level)
Observed: The advisory identifies two reachable paths. Inbound: a request carrying Content-Encoding: br whose body is decompressed by the native decoder. Outbound: a fetch() response carrying Content-Encoding: br that fetch() decompresses automatically. Both feed attacker-influenced bytes into the same native routine, and the reported primitive is an out-of-bounds write.
Inference (exposure): The inbound path matters only if something in your stack decodes br request bodies, whether your framework's body parser, middleware, or an upstream proxy that forwards decoded content. Confirm this per service. The outbound path is the broader risk: any server-side fetch() to a URL you do not control (user-supplied webhooks, SSRF-adjacent features, agent tools) lets the remote party choose the encoding.
Inference (impact): An out-of-bounds write in native code is a memory-corruption primitive. Whether it becomes code execution depends on allocator layout and hardening, and the advisory text is the authority on claimed impact. The 'Node2Shell' label indicates the maintainers' or researchers' assessment, not something to assume for your build.
The first deliverable is a hard answer to 'which Node is this?' for every service. This check treats any line outside 22/24/26 as unpatched, which correctly flags EOL lines.
// Minimum fixed versions per supported line, from the Node.js advisory
const MIN = { 22: [22, 23, 4], 24: [24, 22, 1], 26: [26, 12, 1] };
const v = process.versions.node.split('.').map(Number);
const min = MIN[v[0]];
if (!min) {
console.error('UNPATCHED-LINE (no fix in advisory):', process.version);
process.exit(2);
}
const ok = v[1] > min[1] || (v[1] === min[1] && v[2] >= min[2]);
console.log(ok ? 'PATCHED' : 'VULNERABLE', process.version);
process.exit(ok ? 0 : 1);
03. Immediate Zero-Downtime Triage (Non-Breaking Mitigation)
Inbound stopgap: reject br-encoded request bodies before they reach Node. Browsers do not send compressed request bodies, so the legitimate impact is near zero. Do this at Nginx or the Cloudflare edge, and also add the middleware as defense in depth. It must be registered before any body parser.
Outbound stopgap: for fetches to destinations you do not control, bypass fetch()'s auto-decompression. The helper below uses undici.request, which does not decompress, refuses any encoding except identity, gzip and deflate, and bounds output size. Inference: setting Accept-Encoding alone is not sufficient, because a malicious server can ignore it and reply with br anyway. The post-response check is what protects you.
Cloudflare expression syntax: validate the rule in the dashboard's expression editor before deploying, and set the action to Block.
map $http_content_encoding $reject_br_request {
default 0;
"~*(^|[ ,])br($|[ ,])" 1;
}
server {
# ...
if ($reject_br_request) { return 415; }
}
any(http.request.headers["content-encoding"][*] contains "br")
app.use((req, res, next) => {
const enc = String(req.headers['content-encoding'] || '').toLowerCase();
if (/(^|[ ,])br($|[ ,])/.test(enc)) {
return res.status(415).type('text/plain').send('unsupported content-encoding');
}
next();
});
import { request } from 'undici';
import { gunzipSync, inflateSync } from 'node:zlib';
export async function safeGet(url, { maxBytes = 5_000_000 } = {}) {
const res = await request(url, { headers: { 'accept-encoding': 'gzip, deflate' } });
const enc = String(res.headers['content-encoding'] || 'identity').toLowerCase().trim();
if (!['identity', 'gzip', 'deflate'].includes(enc)) {
res.body.destroy();
throw new Error('blocked content-encoding: ' + enc);
}
const chunks = [];
let n = 0;
for await (const c of res.body) {
n += c.length;
if (n > maxBytes) { res.body.destroy(); throw new Error('response too large'); }
chunks.push(c);
}
const buf = Buffer.concat(chunks);
if (enc === 'gzip') return gunzipSync(buf, { maxOutputLength: maxBytes });
if (enc === 'deflate') return inflateSync(buf, { maxOutputLength: maxBytes });
return buf;
}
04. Permanent Remediation & Cryptographic SBOM Attestation
Permanent fix: run Node.js v22.23.4+, v24.22.1+ or v26.12.1+ everywhere (hosts, containers, serverless layers, CI runners). Migrate any Node 20 service to a fixed line. Pin the engines field and the base-image digest, and run check-node.mjs as a CI step and as a post-deploy smoke test so a stale image cannot ship.
Attestation: generate a CycloneDX SBOM for the built image, scan it, and attach a signed attestation to the image digest. PAIN-2026-005 gives the full pipeline. Floating tags such as node:22-alpine are the most common way an old patch level survives in production.
Primary source: https://nodejs.org/en/blog/vulnerability/
HadayaLab operates with 45x Google Cloud & DeepMind Skill Badges (https://www.skills.google/public_profiles/49cdbdb6-20d4-4661-934a-d882c7f5c6cf). We audit NestJS and Express estates for runtime patch level, egress-fetch exposure and edge filtering, and deliver a signed evidence pack.
Frequently Asked Questions
Which Node.js versions fix Node2Shell (CVE-2026-111497)?
Per the Node.js security release, v22.23.4 and later, v24.22.1 and later, and v26.12.1 and later. Confirm on https://nodejs.org/en/blog/vulnerability/. Run node -v inside each deployed container, not on your laptop.
Why does npm audit show nothing for CVE-2026-111497?
npm audit reads package-lock.json. The Brotli decoder is native code compiled into the Node executable, so it never appears as a dependency. The check that works is the runtime version, as in check-node.mjs.
Is my Express or NestJS API exposed if it never decodes Brotli request bodies?
The inbound path needs something to decode a br request body. If no middleware or upstream proxy does, that path may be closed, but verify it per service. The outbound path still applies to any server-side fetch() to a URL you do not control. Patch the runtime either way.
We are still on Node.js 20. What should we do?
Node 20 reached end-of-life on 2026-04-30 and is not among the fixed lines in the advisory. Apply the edge and egress mitigations now and schedule migration to 22 or 24. Treat Node 20 as unpatched until the advisory says otherwise.