backend #nodejs #security-releases #docker #github-actions #cloudflare-pages #patch-management #agent-ops

Node.js Security Fixes Are Out: Is Every Place You Run Node Past the Fix?

Node.js shipped security releases for lines 22, 24 and 26 on Nov 11, 2026. What the advisory lists, which parts matter for your setup, and a check for every place Node hides.

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 NodeWhat the page says can happenLines
HTTP/2 serverTeardown trips an assertion and kills the process (CVE-2026-123863)24, 26
HTTPS serverA zero-length TLS write can leak memory of other connections; plain node:http is not affected (CVE-2026-123864)22, 24, 26
HTTP/2 serverSessions are never cleaned up after a protocol error, leaking memory and file descriptors (CVE-2026-123865)22, 24, 26
HTTP/2 serverFreed response memory can be sent to a peer (CVE-2026-123866)22, 24, 26
TLS serverRemote crash when the server uses SNICallback, OCSPRequest, newSession, resumeSession or addContext (CVE-2026-126051)24, 26
TLS 1.2A client can change the servername on renegotiation and bypass a per-host client-certificate policy (CVE-2026-123872)22, 24, 26
HTTP/2 file servingMemory 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:

InputWhat it printed
v26.12.1BEHIND: the fix for line 26 is 26.13.1
26.13.1ok: at or above 26.13.1
24.22.2BEHIND: the fix for line 24 is 24.22.3
22.23.4BEHIND: the fix for line 22 is 22.23.5
20.19.0NO FIX on this list: not one of the lines 22/24/26
ltscannot 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.version printed v26.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_VERSION yourself.
  • Docker Hub. The Docker Official Image tags 22, 24, 26 and lts show 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:

LineStatus on Nov 10, 2026In the Nov 11 release?Support ends
26 (Lithium)LTS since 2026-10-28Yes2029-04-30
24 (Krypton)Maintenance LTS since 2026-10-20Yes2028-04-30
22 (Jod)Maintenance LTSYes2027-04-30
25End-of-LifeNo, but always affectedended 2026-06-01
20 (Iron)End-of-LifeNo, but always affectedended 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.

Release lines: 26, 24 and 22 are on the list; 25 and 20 are End-of-Life and affected anyway

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.

PlaceHow to read itThe trap
Your laptopnode -vIt is the one you tested on, so it feels like the answer
The CI runnerthe setup step in the workflow fileThe file says what you asked for, not what the runner had cached
The Docker imagethe FROM node: lineA tag like lts moves without a commit
The build platformits dashboard or a pin fileYou never installed it, so you never looked
The machine your coding agent runs onnode -v in that shellNobody 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.

Five places a Node version lives, each with how to read it and a blank owner line

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:

WhereValueWhat the script said
ci.yml:6 node-version24line only, on Wednesday’s list
ci.yml:11 node-version-file.nvmrcpoints at a file: open it and read it too
ci.yml:16 node-version${{ matrix.node }}indirect: value set elsewhere, follow it
.nvmrc:122line only, on Wednesday’s list
Dockerfile:1 FROM node:20-alpineNode 20, EOL: always affected, not on the list
Dockerfile:5 FROM node:lts-trixie-slimfloating: moves when someone re-pulls or rebuilds
docker-compose.yml:325Node 25, EOL: always affected, not on the list
package.json:1 engines.node>=18range, 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.

The file says what you asked for; the running process says what you got

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.

  1. Run the floor check on every row of your list: python3 node_fix_floor.py "$(node -p process.version)".
  2. Patch in this order: servers where Node terminates TLS or HTTP/2, Node behind a proxy, CI runners and agent hosts, then laptops.
  3. Rebuild Docker images with --pull --no-cache and make the build print process.version.
  4. Write next to each row whether Node answers network requests there or only builds assets.
  5. Move every End-of-Life line (Node 20, Node 25) to a supported one, with an owner and a date.
  6. Replace floating names (lts, latest, current) with a named line, and set NODE_VERSION on your build platform.
  7. Put one name next to each row, one person, not a team.

Further reading