On Wednesday, Node.js shipped the security fixes it had announced. The page that listed nothing on Tuesday now lists them all.
So the question in your team chat has changed. It is no longer “What will they fix?” It is “Are we affected?”, and the honest first answer is still a question back: “Which Node, and where?”
This post began on Nov 10 as a way to answer that before the details landed. Now I have added what the advisory lists and a check against the fixed versions. The question is not whether Node patched it. It is whether every place you run Node got the patch.
What the advisory lists now
I re-read the advisory page live on Nov 12 at about 08:00 Jakarta time. Under Downloads it names three fixed versions: 22.23.5, 24.22.3 and 26.13.1. It says the highest severity fixed in each line is HIGH.
It lists 16 CVEs: 7 high, 6 medium and 3 low. The release notes for 24.22.3 add one more low item, CVE-2026-123873, which the advisory page does not list. So “16” means what the advisory page lists, not a final count. The release also updates llhttp, undici and sqlite.
Here are the seven HIGH items in plain words, from the page’s own descriptions:
| Part of Node | What the page says can happen | Lines |
|---|---|---|
| HTTP/2 server | Teardown trips an assertion and kills the process (CVE-2026-123863) | 24, 26 |
| HTTPS server | A zero-length TLS write can leak memory of other connections; plain node:http is not affected (CVE-2026-123864) | 22, 24, 26 |
| HTTP/2 server | Sessions are never cleaned up after a protocol error, leaking memory and file descriptors (CVE-2026-123865) | 22, 24, 26 |
| HTTP/2 server | Freed response memory can be sent to a peer (CVE-2026-123866) | 22, 24, 26 |
| TLS server | Remote crash when the server uses SNICallback, OCSPRequest, newSession, resumeSession or addContext (CVE-2026-126051) | 24, 26 |
| TLS 1.2 | A client can change the servername on renegotiation and bypass a per-host client-certificate policy (CVE-2026-123872) | 22, 24, 26 |
| HTTP/2 file serving | Memory corruption when a respondWithFile() stream is reset (CVE-2026-123874) | 24, 26 |
The page words every impact line as “affects all users in active release lines”, and then describes triggers, such as a handler that responds before the request body ends. I read that as: patch every listed line, and use the trigger only to decide the order.
All seven describe Node answering network requests as an HTTP/2, HTTPS or TLS server. None describes a build step. My inference, not the page’s: if Node only compiles your CSS and JavaScript in CI, these seven are not a hole in your build, though the host should still move. First in line are servers where Node itself terminates TLS or HTTP/2: server-side rendering, or a bare Express or Fastify server over HTTPS. Node behind nginx or Cloudflare comes next.
One medium item is for CI and agent hosts. CVE-2026-123871: an invalid proxy setting can print the raw proxy URL, credentials included, in an ERR_PROXY_INVALID_CONFIG message, which then lands in stderr, container logs or crash reports.
Check a version against the fix
A list of versions is only useful if something compares it with the fix. This script says which versions are at or above the fixed version for their line. It judges only exact x.y.z values, because lts or 22 can mean anything, and it exits 1 if any value is behind, so a CI step can fail on it.
1#!/usr/bin/env python3
2"""Compare Node.js versions with the Nov 11, 2026 security-release floors.
3Usage: node_fix_floor.py VERSION [VERSION ...] e.g. v26.12.1 24.22.3 lts
4Exit 0 = every version is at or above its line's fix.
5Exit 1 = at least one is behind, unfixed, or cannot be judged.
6Exit 2 = no input."""
7import re
8import sys
9
10# From the "Downloads" list on the advisory page.
11FIXED = {22: (22, 23, 5), 24: (24, 22, 3), 26: (26, 13, 1)}
12
13
14def judge(raw):
15 text = raw.strip()
16 m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", text)
17 if not m:
18 return False, "cannot judge: not an exact x.y.z (a floating tag or a line)"
19 ver = tuple(int(part) for part in m.groups())
20 line = ver[0]
21 if line in FIXED:
22 floor = FIXED[line]
23 want = ".".join(map(str, floor))
24 if ver >= floor:
25 return True, f"ok: at or above {want}"
26 return False, f"BEHIND: the fix for line {line} is {want}"
27 if line > 26:
28 return False, "cannot judge: newer than the lines on this list, read its notes"
29 return False, "NO FIX on this list: not one of the lines 22/24/26"
30
31
32def main(argv):
33 if len(argv) < 2:
34 print("give at least one version", file=sys.stderr)
35 return 2
36 worst = 0
37 for raw in argv[1:]:
38 good, why = judge(raw)
39 print(f"{raw:<12} {why}")
40 if not good:
41 worst = 1
42 return worst
43
44
45if __name__ == "__main__":
46 sys.exit(main(sys.argv))
I ran it on a mix of inputs:
| Input | What it printed |
|---|---|
v26.12.1 | BEHIND: the fix for line 26 is 26.13.1 |
26.13.1 | ok: at or above 26.13.1 |
24.22.2 | BEHIND: the fix for line 24 is 24.22.3 |
22.23.4 | BEHIND: the fix for line 22 is 22.23.5 |
20.19.0 | NO FIX on this list: not one of the lines 22/24/26 |
lts | cannot judge: not an exact x.y.z |
With v26.13.1 and 24.22.3 the exit code was 0. With no input it exits 2. To make a job fail when its Node is behind, add one step after setup:
1 - name: Fail if this job's Node is behind the Nov 11 fix
2 run: python3 tools/node_fix_floor.py "$(node -p process.version)"
The floor moves with every security release, so keep the file in a repository with an owner.
Where my own list stands this morning
- My agent host.
node -p process.versionprintedv26.12.1. The script says BEHIND, because the 26 line’s fix is 26.13.1. The host reported the same value on Nov 10. It has not moved; the target did. - Cloudflare Pages. The build image page, read live, was last updated Oct 26 and still lists 22.23.4 as the default Node, one patch below 22.23.5. I do not know when the default moves. If you want to decide, set
NODE_VERSIONyourself. - Docker Hub. The Docker Official Image tags
22,24,26andltsshow updates between Nov 11 19:25 UTC and Nov 12 00:56 UTC. I did not pull them. A new base image does not change your image: it gets the fix only when you rebuild with a fresh pull.
What the page said on Nov 10, before the release
On Nov 10 the page named three lines, gave HIGH as the ceiling, and had an empty list. One sentence on it still matters: “End-of-Life versions are always affected when a security release occurs.”
A “release line” is the number before the first dot: Node 22 is one line, 22.23.4 one version inside it. The schedule, from nodejs/Release on Nov 10:
| Line | Status on Nov 10, 2026 | In the Nov 11 release? | Support ends |
|---|---|---|---|
| 26 (Lithium) | LTS since 2026-10-28 | Yes | 2029-04-30 |
| 24 (Krypton) | Maintenance LTS since 2026-10-20 | Yes | 2028-04-30 |
| 22 (Jod) | Maintenance LTS | Yes | 2027-04-30 |
| 25 | End-of-Life | No, but always affected | ended 2026-06-01 |
| 20 (Iron) | End-of-Life | No, but always affected | ended 2026-04-30 |
LTS means long-term support, the lines meant for production. Maintenance LTS gets fixes but no new features. End-of-Life means no more releases.

Five places a Node version hides
Ask a team “which Node do you run?” and you usually get one answer. The honest answer is often five.
| Place | How to read it | The trap |
|---|---|---|
| Your laptop | node -v | It is the one you tested on, so it feels like the answer |
| The CI runner | the setup step in the workflow file | The file says what you asked for, not what the runner had cached |
| The Docker image | the FROM node: line | A tag like lts moves without a commit |
| The build platform | its dashboard or a pin file | You never installed it, so you never looked |
| The machine your coding agent runs on | node -v in that shell | Nobody put it on any list |
The last row is the one I want you to look at. A coding agent runs shell commands, and shell commands call whatever node is on the path. Nobody chose that Node. It came with the host.
My own agent host was one of those rows, and its reading is in the section above.
The repository for this very site has no .nvmrc and no package.json, because Hugo builds it. “Not here” is a finding worth writing down. Silence is not.

Step one: read the files
Files tell you what a human asked for, and they are the cheapest thing to scan. This script walks a repository and lists every place that names a Node version: .nvmrc, .node-version, package.json, Dockerfile, compose files and GitHub workflows. For each hit it says which release line the value belongs to and whether that line is in the Nov 11 release.
It uses only the Python standard library and fails closed: a folder it cannot read exits with code 3 instead of printing a calm, empty report.
1#!/usr/bin/env python3
2"""List every place a repo names a Node.js version. Usage: find_node_pins.py PATH
3Exit 3 = PATH unreadable. Finding nothing is NOT a clean result."""
4import json
5import os
6import re
7import sys
8
9# Release lines, checked against nodejs/Release schedule.json on 2026-11-10.
10# (status, on the Nov 11 security release list?)
11LINES = {
12 20: ("EOL since 2026-04-30", False),
13 22: ("Maintenance LTS, EOL 2027-04-30", True),
14 24: ("Maintenance LTS, EOL 2028-04-30", True),
15 25: ("EOL since 2026-06-01", False),
16 26: ("LTS, EOL 2029-04-30", True),
17 27: ("alpha, not released", False),
18}
19CODENAMES = {"iron": 20, "jod": 22, "krypton": 24, "lithium": 26}
20FLOATING = ("lts", "latest", "current", "node", "stable")
21SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "public", ".venv"}
22
23
24def classify(spec):
25 raw = spec.strip().strip("'\"")
26 low = raw.lower()
27 if not raw:
28 return "empty", "read by a human"
29 if "$" in raw or "{{" in raw:
30 return "indirect", "value set elsewhere: follow it"
31 if re.search(r"[<>^~|\s]", raw) and not low.startswith(("lts/", "lts-")):
32 return "range", "range, not a pin: check what really runs"
33 if low.startswith(FLOATING):
34 return "floating", "moves when someone re-pulls or rebuilds"
35 m = re.match(r"^v?(\d+)", low)
36 if m:
37 major = int(m.group(1))
38 else:
39 major = next((v for k, v in CODENAMES.items() if low.startswith(k)), None)
40 if major is None:
41 return "unknown", "read by a human"
42 status, listed = LINES.get(major, ("older than 20", False))
43 exact = bool(re.match(r"^v?\d+\.\d+\.\d+", low))
44 kind = "exact patch" if exact else "line only"
45 verdict = (
46 "on Wednesday's list" if listed else "EOL: always affected, not on the list"
47 )
48 return f"{kind} (node {major}, {status})", verdict
49
50
51def read_lines(path):
52 with open(path, encoding="utf-8", errors="replace") as fh:
53 return fh.read().splitlines()
54
55
56def scan_file(path, rel):
57 name = os.path.basename(path)
58 out = []
59 lines = read_lines(path)
60 if name in (".nvmrc", ".node-version"):
61 for i, ln in enumerate(lines, 1):
62 if ln.strip():
63 out.append((rel, i, name, ln.strip()))
64 break
65 elif name == "package.json":
66 try:
67 data = json.loads("\n".join(lines))
68 except ValueError:
69 out.append((rel, 1, "package.json", "UNREADABLE JSON"))
70 return out
71 eng = (data.get("engines") or {}).get("node")
72 if eng:
73 out.append((rel, 1, "engines.node", eng))
74 vol = (data.get("volta") or {}).get("node")
75 if vol:
76 out.append((rel, 1, "volta.node", vol))
77 elif name.lower().startswith("dockerfile") or name.lower().endswith(".dockerfile"):
78 pat = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?node:(\S+)", re.I)
79 for i, ln in enumerate(lines, 1):
80 m = pat.match(ln)
81 if m:
82 out.append((rel, i, "FROM node:", m.group(1)))
83 elif re.search(r"(^|/)(docker-)?compose[^/]*\.ya?ml$", rel):
84 pat = re.compile(r"^\s*image:\s*['\"]?node:([^\s'\"#]+)")
85 for i, ln in enumerate(lines, 1):
86 m = pat.match(ln)
87 if m:
88 out.append((rel, i, "compose image: node:", m.group(1)))
89 elif ".github/workflows/" in rel and name.endswith((".yml", ".yaml")):
90 pat = re.compile(r"^\s*-?\s*(node-version(?:-file)?):\s*(.+?)\s*(?:#.*)?$")
91 for i, ln in enumerate(lines, 1):
92 m = pat.match(ln)
93 if m:
94 out.append((rel, i, m.group(1), m.group(2)))
95 return out
96
97
98def main(argv):
99 if len(argv) != 2 or not os.path.isdir(argv[1]) or not os.access(argv[1], os.R_OK):
100 print("ERROR: give a readable repo folder", file=sys.stderr)
101 return 3
102 root = argv[1]
103 found = []
104 for base, dirs, files in os.walk(root):
105 dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
106 for f in files:
107 path = os.path.join(base, f)
108 rel = os.path.relpath(path, root)
109 try:
110 found.extend(scan_file(path, rel))
111 except OSError as exc:
112 print(f"ERROR: cannot read {rel}: {exc}", file=sys.stderr)
113 return 3
114 if not found:
115 print("NO NODE PINS FOUND. This is not 'safe'. The version comes from")
116 print("somewhere this scan cannot see: host default, base image, laptop.")
117 return 0
118 print("| where | what | value | what it is | Wednesday |")
119 print("|---|---|---|---|---|")
120 for rel, line, what, value in sorted(found):
121 kind, verdict = classify(value)
122 if what == "node-version-file":
123 kind, verdict = "points at a file", f"open {value.strip()} and read it too"
124 print(f"| {rel}:{line} | {what} | `{value}` | {kind} | {verdict} |")
125 return 0
126
127
128if __name__ == "__main__":
129 sys.exit(main(sys.argv))
I ran it on a fixture repository that I built to look like a small shop: an .nvmrc, a two-stage Dockerfile, a compose file, a package.json and a workflow. Here are the real rows it printed, trimmed to three columns:
| Where | Value | What the script said |
|---|---|---|
ci.yml:6 node-version | 24 | line only, on Wednesday’s list |
ci.yml:11 node-version-file | .nvmrc | points at a file: open it and read it too |
ci.yml:16 node-version | ${{ matrix.node }} | indirect: value set elsewhere, follow it |
.nvmrc:1 | 22 | line only, on Wednesday’s list |
Dockerfile:1 FROM node: | 20-alpine | Node 20, EOL: always affected, not on the list |
Dockerfile:5 FROM node: | lts-trixie-slim | floating: moves when someone re-pulls or rebuilds |
docker-compose.yml:3 | 25 | Node 25, EOL: always affected, not on the list |
package.json:1 engines.node | >=18 | range, not a pin: check what really runs |
Eight rows, and the fixture is tiny. In a real repository the CI file, the Dockerfile and the pin file will not agree with each other. That is the finding. On this site’s repository it printed “NO NODE PINS FOUND” and a warning that this is not the same as safe.
Step two: ask the running thing, not the file
A file says what someone wanted. Only the running process says what it got. On any machine where a person or an agent runs commands, three lines tell you the truth:
1node -p "process.version" # the Node on this machine, right now
2which node # which copy, in case there are two
3echo "${NODE_VERSION:-unset}" # some platforms read this variable
In CI, make the job say out loud which Node it got. This setup step reads the version from the same file your laptop uses and prints the answer into the log:
1jobs:
2 test:
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v7
6 - uses: actions/setup-node@v7
7 with:
8 node-version-file: .nvmrc
9 check-latest: true
10 - name: Print the Node this job really got
11 run: node -p "'node ' + process.version + ' on ' + process.platform"
I parse-checked that YAML with a YAML library. I did not run it on GitHub. By the actions/setup-node documentation, check-latest defaults to false: the action takes a match from the runner’s cache first, so an older patch inside your line can survive a release. check-latest: true makes it confirm the cached copy is the newest first, which is slower.
For Docker, name the line and rebuild with a fresh pull:
1# Name the line you chose on purpose, not "lts".
2FROM node:24-trixie-slim
3RUN node -p "'built with node ' + process.version"
1docker build --pull --no-cache -t myapp:node-patch .
--pull always tries to fetch a newer base image, and --no-cache stops Docker reusing an old layer. Pin the line, not the patch: a pin to one exact patch is out of date at the next release. The line, a rebuild habit and a log line that prints the version are less to maintain.

Floating names move without asking you
lts, latest and current look like pins and are moving labels.
On Nov 10 the Docker Official Image docs put the tags 26, current, latest, lithium, lts and lts-lithium on the same build of the 26 line. Since 26 became LTS on 2026-10-28, an image built from node:lts in September and one built this week can be different majors. Nobody edited the Dockerfile. The label moved.
Cloudflare Pages does it quietly: unless you set NODE_VERSION or a pin file, its default Node “may be updated to the latest available minor version without notice”. On Nov 10 the same page said the v3 system does not read engines in package.json, so that row in the scanner table above means nothing there.
The loud part is the old lines
The release names three lines. Your problem is more likely a fourth.
Node 20 reached End-of-Life on 2026-04-30 and Node 25 on 2026-06-01. The announcement says End-of-Life versions are always affected, and no fix is coming for them. In the fixture above, two of eight rows are on an End-of-Life line. I expect the old line to hide where nobody looks: a worker container, a one-off script image, a CI job added for one release. The fix is a move to a supported line, a planned task with an owner.
What this connects to
Same habit as A Red Fork PR on Nov 9 or 16 May Be a GitHub Rehearsal, Not Your Bug (workflows with an owner) and Your Overnight Agent Job Has a Login With an End Date (jobs with a date), here for the runtime. A Package Cooldown Also Delays Your Security Fix is the package-side twin. Long-Running AI Agents: From Demos to Production covers the agent side. More notes of this kind are on AI Agent Operations and Developer Tools, and Start Here lists the reading order.
What I did and did not verify
On 2026-11-12 at about 08:00 Jakarta time I read the advisory page and the 24.22.3 release notes live. I also read nodejs.org/dist/index.json (it lists 26.13.1, 24.22.3 and 22.23.5 as the newest of their lines), Cloudflare’s build image page, and the Docker Hub tag records for 22, 24, 26 and lts (update times only). I did not open the notes for 22.23.5 or 26.13.1, so the CVE-to-line mapping comes from the advisory page alone.
I ran the floor script on the inputs shown and on my own host. On Nov 10 I ran the scanner on a fixture, an empty folder, a missing path (exit code 3) and this site’s repository. I did not pull any Docker image, run the CI step on GitHub, or test any CVE. Re-read the sources before you act, because a later release can move the floors.
What you should do Monday morning
These fit in one sitting, whatever day you read this.
- Run the floor check on every row of your list:
python3 node_fix_floor.py "$(node -p process.version)". - Patch in this order: servers where Node terminates TLS or HTTP/2, Node behind a proxy, CI runners and agent hosts, then laptops.
- Rebuild Docker images with
--pull --no-cacheand make the build printprocess.version. - Write next to each row whether Node answers network requests there or only builds assets.
- Move every End-of-Life line (Node 20, Node 25) to a supported one, with an owner and a date.
- Replace floating names (
lts,latest,current) with a named line, and setNODE_VERSIONon your build platform. - Put one name next to each row, one person, not a team.
