Node.jsの脆弱性対応とは、自分が使っているすべてのNode.jsの版を洗い出し、公式のセキュリティリリースで出た修正版より古いものとサポートが終わった系列(EOL)のものを見つけて上げ、上げたあとに実際の実行環境で版を確かめるまでの作業です。ローカルの node -v を見るだけでは終わりません。
Node.jsは、開発機・Dockerイメージ・CI・Lambdaのようなマネージドの実行環境・Electronで作られたアプリの中など、1つのチームの中でも別々の場所で動いています。どれか1つを上げても、ほかの場所は古い版のまま動き続けます。とくにCIとDockerは「最新を使う指定にしたつもりが、キャッシュの古い版で動いていた」ということが起きやすい場所です。
この記事では、2026年10月21日のセキュリティリリース(Brotliの脆弱性 CVE-2026-111497 を含む)を例に、Node.jsが動いている場所の洗い出し、修正版かの判定、場所ごとの上げ方、上げたあとの確かめ方を、そのまま使えるスクリプトつきでまとめます。スクリプトは2026年11月5日に、macOS(Apple Silicon)のNode.js 23.10.0で動かして確かめました(fetchの確認は22.20.0でも動かしています)。公式の発表(Node.js公式のセキュリティリリース)・Node.jsのリリース一覧・CISAの悪用済み脆弱性カタログも同じ日に確認しています。
結論:洗い出す→判定する→上げる→確かめるの4段で回す
Node.jsのセキュリティリリースが出たら、次の4段を毎回同じ順で回します。2段目までは機械的に終わるので、スクリプトにしておくと次の回から数分で済みます。
- 洗い出す:Node.jsが動いている場所と、版を決めている指定(
.nvmrc・Dockerfile・CIの設定・package.jsonなど)を一覧にする - 判定する:一覧の版を、その系列の最新のセキュリティリリースと比べる。EOLの系列は修正版が出ないので別扱いにする
- 上げる:場所ごとに上げ方が違う。CIとDockerは指定を変えなくても古い版が残ることがある
- 確かめる:設定ファイルではなく、実際に動いている環境で
process.versionを出して比べる
落とし穴は3つあります。1つめは、同梱しているBrotliの版数では判定できないこと(後で詳しく書きます)。2つめは、CIの node-version: 24 のようなメジャー版だけの指定は、最新の24.xを使うとは限らないこと。3つめは、Electronのアプリは中にNode.jsを持っていて、Node.js本体を上げても変わらないことです。
2026年10月21日のセキュリティリリースで直ったもの
Node.js公式の発表(Wednesday, October 21, 2026 Security Releases)によると、このリリースは26.x・24.x・22.xの3系列が対象です。主な脆弱性は次のとおりです(深刻度は公式の表記)。
| CVE | 内容 | 深刻度 | 対象の系列 |
|---|---|---|---|
| CVE-2026-111497 | 同梱のBrotliの展開処理で、壊れた圧縮データを渡すと内部のバッファの外へ書き込む。クラッシュ・メモリの漏えい・リモートでのコード実行につながりうる | High | 26.x・24.x・22.x |
| CVE-2026-105482 | dns.resolve*() と dns.reverse() が、NUL文字を含むホスト名を途中で切って別の名前を引く | High | 26.x・24.x・22.x |
| CVE-2026-105483 | 全角数字で書いたIPv4のホストで、TLSの証明書のホスト名確認をすり抜ける | High | 26.x・24.x |
| CVE-2026-106069 | 先頭に0が付いたIPv4のホストで、別のIPの証明書で認証が通る | High | 26.x・24.x・22.x |
| CVE-2026-105481 | keep-aliveで使い回したソケットで、リクエストより前に届いた応答を正しい応答として読む | Medium | 26.x・24.x・22.x |
| CVE-2026-106072 | 0177.0.0.1 のような書き方のIPv4で、DNS名だけの証明書がIPの接続先の認証に使われる | Medium | 26.x・24.x・22.x |
修正版は 22.23.4・24.22.1・26.12.1 です。表のほかにも、crypto.randomUUID() の乱数のキャッシュ(Medium)や、Permission Model のファイルの許可(Low)などの修正が含まれます。2026年10月28日に26系列は26.13.0でLTSになったので、26系列は26.13.0を入れても修正が入っています。同じリリースでc-ares・Undici・OpenSSL(24.x・22.x)・SQLite(22.x)・CA証明書の更新も入っています。
このうちBrotliの CVE-2026-111497 は、2026年10月27日にCISAの悪用済み脆弱性カタログ(KEV)に載りました(米国の連邦機関の対応期限は2026年11月10日)。KEVに載ったものは「攻撃に使われていることが確認された」ものなので、ほかの脆弱性より先に片付けます。KEVへの登録を目安に対応の順番を決める例はWordPress 7.1.2で修正された脆弱性CVE-2026-87902の影響確認と対応手順でも同じ型で扱っています。
Brotliの脆弱性が届く経路
公式の説明では、影響を受けるのは「攻撃者が用意したBrotliのデータを、node:zlib で展開するアプリ」です。自分のコードで zlib.brotliDecompress を呼んでいなくても安心はできません。Node.js標準の fetch は、応答のヘッダーが content-encoding: br なら自動でBrotliを展開します。
次のコードで確かめました。Brotliで圧縮した本文を返すサーバーを立て、fetch で取るだけです。fetchの中身(Undici)が zlib.createBrotliDecompress を使っていることはUndiciのソースでも確かめられます。
import { createServer } from "node:http";
import { brotliCompressSync } from "node:zlib";
const body = brotliCompressSync(Buffer.from("fetch が Brotli を自動で展開した"));
const server = createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "content-encoding": "br" });
res.end(body);
});
server.listen(0, "127.0.0.1", async () => {
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/`);
console.log(res.headers.get("content-encoding"), "→", await res.text());
} catch (error) {
console.error("取得に失敗しました:", error.message);
process.exitCode = 1;
} finally {
server.close();
}
});Node.js 22.20.0と23.10.0のどちらでも、結果は同じでした。
$ node fetch-br.mjs
br → fetch が Brotli を自動で展開したつまり、外部のURLを fetch で取りに行くサーバー(OGPの取得・クローラー・画像のプロキシ・外部APIの呼び出しなど)は、相手のサーバーが送ってくるBrotliをそのまま展開します。「うちは圧縮を扱っていない」と判断する前に、外へリクエストを出す処理があるかを見てください。
手順1:Node.jsが動いている場所を洗い出す
最初にやることは、Node.jsが動いている場所と、それぞれの版を何が決めているかを一覧にすることです。場所ごとに、版の決まり方と確かめ方が違います。
| 場所 | 版を決めているもの | 実際の版の確かめ方 |
|---|---|---|
| 開発機 | nvm・Volta・asdf/mise・Homebrewなど。1台に複数入っていることが多い | which -a node で全部を出し、それぞれ --version |
| リポジトリ | .nvmrc・.node-version・.tool-versions・package.json の engines/volta | 下のスクリプトで一覧にする |
| Dockerイメージ | Dockerfileの FROM node:… のタグ | ビルドしたイメージの中で node -v |
| CI(GitHub Actions) | actions/setup-node の node-version | ジョブのログに出る版 |
| Lambdaなどのマネージドの実行環境 | ランタイムの指定(nodejs24.x など)と、ランタイムの更新方式 | 関数の中で process.version をログに出す |
| Electronで作られたアプリ | アプリが使っているElectronの版 | Electronの版から同梱のNode.jsの版を引く |
リポジトリの版の指定を一覧にするスクリプト
リポジトリの中の版の指定は、ファイルの種類がばらばらで探しにくいところです。次のスクリプトは、指定したフォルダの下を node_modules や .git を飛ばしながら読み、版を決めている箇所だけを表にします。依存は使わず、Node.jsだけで動きます。
import { readdir, readFile } from "node:fs/promises";
import { join, relative, basename } from "node:path";
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "vendor"]);
const RULES = [
{ test: (n) => n === ".nvmrc" || n === ".node-version", kind: "バージョン指定ファイル", pick: (t) => [t.trim()] },
{ test: (n) => n === ".tool-versions", kind: "asdf / mise", pick: (t) => [...t.matchAll(/^nodejs\s+(\S+)/gm)].map((m) => m[1]) },
{
test: (n) => n === "package.json",
kind: "package.json",
pick: (t) => {
const pkg = JSON.parse(t);
return [
pkg.engines?.node && `engines: ${pkg.engines.node}`,
pkg.volta?.node && `volta: ${pkg.volta.node}`,
].filter(Boolean);
},
},
{
test: (n) => /^Dockerfile/.test(n) || /\.dockerfile$/i.test(n),
kind: "Dockerfile",
pick: (t) => [...t.matchAll(/^\s*FROM\s+(?:--platform=\S+\s+)?(\S*node:\S+)/gim)].map((m) => m[1]),
},
{
test: (n, dir) => /\.ya?ml$/.test(n) && dir.endsWith(join(".github", "workflows")),
kind: "GitHub Actions",
pick: (t) => [...t.matchAll(/node-version:\s*['"]?([^'"\s#]+)/g)].map((m) => m[1]),
},
];
const walk = async (dir) => {
const entries = await readdir(dir, { withFileTypes: true });
const nested = await Promise.all(
entries.map((e) => {
const path = join(dir, e.name);
if (e.isDirectory()) return SKIP_DIRS.has(e.name) ? [] : walk(path);
return e.isFile() ? [path] : [];
}),
);
return nested.flat();
};
const inspect = async (root, path) => {
const name = basename(path);
const dir = join(path, "..");
const rule = RULES.find((r) => r.test(name, dir));
if (!rule) return [];
try {
const values = rule.pick(await readFile(path, "utf8"));
return values.map((value) => ({ file: relative(root, path), kind: rule.kind, value }));
} catch (error) {
return [{ file: relative(root, path), kind: rule.kind, value: `読めない: ${error.message}` }];
}
};
const main = async () => {
const root = process.argv[2] ?? ".";
const files = await walk(root);
const rows = (await Promise.all(files.map((f) => inspect(root, f)))).flat();
console.table(rows);
};
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});api・web・worker の3つのフォルダを作って動かした結果です。worker には版の指定が無いので1行も出ていません(表示は console.table の出力を表に写したもの)。node_modules の中の package.json は拾っていません。
$ node find-node-pins.mjs fixtures| file | kind | value |
|---|---|---|
| api/.github/workflows/ci.yml | GitHub Actions | 22.x |
| api/.github/workflows/ci.yml | GitHub Actions | 24.22.0 |
| api/.nvmrc | バージョン指定ファイル | 22.23.3 |
| api/Dockerfile | Dockerfile | node:22-alpine |
| api/Dockerfile | Dockerfile | node:22.23.3-slim |
| web/.tool-versions | asdf / mise | 26.12.0 |
| web/package.json | package.json | engines: >=20 |
| web/package.json | package.json | volta: 24.22.0 |
手元のリポジトリで数えた結果
同じスクリプトを手元のリポジトリ群(~/Projects 配下)に流すと、版の指定は31か所見つかり、そのうち27か所がGitHub Actionsの node-version でした。27か所の値の内訳は 22 が13か所、22.19 が8か所、20 と 24 が3か所ずつです。メジャー版より細かく指定していたのは1つのリポジトリの8か所(すべて 22.19 で、パッチ版までは決めていない)だけで、残りの19か所はメジャー版だけの指定でした。.nvmrc やDockerfileより、CIの設定が版の指定の大半を占めていたことになります。CIの上げ方は手順3で書きます。
手順2:修正版より古いか、EOLかを判定する
判定は「その系列の最新のセキュリティリリース」と比べるのが確実です。Node.jsは全リリースの一覧をnodejs.org/dist/index.jsonで公開していて、各リリースに security(セキュリティの修正を含むか)が付いています。サポート期限はnodejs/Releaseのschedule.jsonにあります。この2つを読めば、版を渡すだけで判定できます。
const INDEX_URL = "https://nodejs.org/dist/index.json";
const SCHEDULE_URL = "https://raw.githubusercontent.com/nodejs/Release/main/schedule.json";
const parse = (v) => v.replace(/^v/, "").split(".").map(Number);
const compare = (a, b) => {
const [x, y] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return 0;
};
const fetchJson = async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} の取得に失敗しました(HTTP ${res.status})`);
return res.json();
};
const judge = (version, releases, schedule, today) => {
const major = parse(version)[0];
const line = schedule[`v${major}`];
if (!line || new Date(line.end) < today) {
return { version, status: "EOL", detail: "修正版が出ない系列。サポート中の系列へ移る" };
}
const sameLine = releases.filter((r) => parse(r.version)[0] === major);
const lastSecurity = sameLine.find((r) => r.security);
if (!lastSecurity) {
return { version, status: "OK", detail: "この系列にセキュリティリリースはまだ無い" };
}
if (compare(version, lastSecurity.version) < 0) {
return {
version,
status: "要更新",
detail: `${lastSecurity.version}(${lastSecurity.date})以降へ`,
};
}
return { version, status: "OK", detail: `最新のセキュリティリリース ${lastSecurity.version} 以降` };
};
const main = async () => {
const targets = process.argv.slice(2);
const versions = targets.length > 0 ? targets : [process.version];
const [releases, schedule] = await Promise.all([fetchJson(INDEX_URL), fetchJson(SCHEDULE_URL)]);
const today = new Date();
const rows = versions.map((v) => judge(v, releases, schedule, today));
console.table(rows);
if (rows.some((r) => r.status !== "OK")) process.exitCode = 1;
};
main().catch((error) => {
console.error(error.message);
process.exitCode = 2;
});版を並べて渡した結果です(console.table の出力を表に写したもの)。引数を省くと、動かしたNode.js自身の版を判定します。
$ node check-node-security.mjs v20.19.5 v22.23.3 v22.23.4 v23.10.0 v24.22.0 v24.22.1 v26.12.0 v26.13.0| version | status | detail |
|---|---|---|
| v20.19.5 | EOL | 修正版が出ない系列。サポート中の系列へ移る |
| v22.23.3 | 要更新 | v22.23.4(2026-10-20)以降へ |
| v22.23.4 | OK | 最新のセキュリティリリース v22.23.4 以降 |
| v23.10.0 | EOL | 修正版が出ない系列。サポート中の系列へ移る |
| v24.22.0 | 要更新 | v24.22.1(2026-10-20)以降へ |
| v24.22.1 | OK | 最新のセキュリティリリース v24.22.1 以降 |
| v26.12.0 | 要更新 | v26.12.1(2026-10-21)以降へ |
| v26.13.0 | OK | 最新のセキュリティリリース v26.12.1 以降 |
1つでも OK 以外があると終了コードが1になるので、CIのジョブで node check-node-security.mjs を最初に流せば、古い版のまま進むのを止められます。手元で、EOLの版で動かすと終了コードが1になることを確かめました。
なお、index.json では22.23.4と24.22.1の日付が発表日の前日の2026-10-20になっています。判定には影響しません。
Brotliの版数では判定できない
Brotliの脆弱性なので process.versions.brotli を見ればよさそうに思えますが、この値では判定できません。Node.jsのソースで、同梱のBrotliの版の定義(deps/brotli/c/common/version.h)をタグごとに確かめた結果です。
| Node.jsの版 | 修正 | 同梱Brotliの版の表記 |
|---|---|---|
| 22.23.3 | 前 | 1.1.0 |
| 22.23.4 | 後 | 1.1.0 |
| 26.12.0 | 前 | 1.2.0 |
| 26.12.1 | 後 | 1.2.0 |
Node.jsは今回、Brotliの修正のコミットだけを取り込んでいて、版の表記は変わっていません(Brotli本体の修正版は1.1.1・1.2.1)。判定はNode.js自身の版で行い、同梱ライブラリの版数は使わないのが正解です。
EOLの系列は修正版が出ない
2026年11月5日時点でサポートが続いているのは22・24・26の3系列です(22は2027年4月30日まで、24は2028年4月30日まで、26は2029年4月30日まで)。20は2026年4月30日に、奇数の23と25はそれぞれ2025年6月1日と2026年6月1日にサポートが終わっています。公式の発表も「サポートが終わった版は、セキュリティリリースのたびに必ず影響を受ける」と書いています。EOLの版が見つかったら、同じメジャーの中で上げるのではなく、サポート中の系列へ移る作業になります。
手順3:場所ごとに上げる。CI・Docker・Lambda・Electronの注意点
版を上げる作業そのものより、「上げたつもりで古い版が残る」のを防ぐほうが大事です。場所ごとの注意点をまとめます。
| 場所 | 古い版が残る理由 | やること |
|---|---|---|
| CI(GitHub Actions) | actions/setup-node の check-latest は既定が false。この場合、ランナーにキャッシュされている版が条件に合えばそれを使う(setup-nodeの公式ドキュメント) | check-latest: true にするか、24.22.1 のように修正版を明記する |
| Dockerイメージ | node:24-alpine のタグは新しい版を指すようになっても、手元やCIに残っている古いイメージでビルドされることがある | ビルドで --pull(参照するイメージを常に取り直す・Dockerの公式リファレンス)を付ける。タグを修正版の番号で固定する |
| Lambda | ランタイムの更新方式が「Manual」だと、指定したランタイムの版を使い続ける。「Function update」は関数を更新したときだけ上がる。既定の「Auto」は自動で新しい版へ上がる(AWSの公式ドキュメント) | 更新方式を確かめ、Function updateなら関数をデプロイし直す。Lambdaのランタイムの版の番号はNode.jsの版とは別の番号なので、関数の中で process.version を出して確かめる |
| Electronで作られたアプリ | アプリはNode.jsを中に持っている。Node.js本体を上げても変わらない | アプリの更新を待って入れる。Electronの版と同梱のNode.jsの版の対応はElectronのreleases.jsonの node 欄で引ける |
| EOLの系列 | 修正版が出ない | サポート中の系列(22・24・26)へ移る |
Electronの対応表の例を挙げると、2026年11月5日時点の releases.json では、Electron 43.8.3はNode.js 24.22.0を、43.8.4はNode.js 24.22.1を同梱しています。アプリ自体が影響を受けるかはアプリの作りによるので、各アプリの発表を見て判断します。
CIの設定を触るついでに、依存のinstallスクリプトの扱いも見直すと手戻りが減ります。npm 12で既定が変わった点はnpm 12のinstallスクリプト停止|壊れる依存と許可の決め方にまとめています。
手順4:実際に動いている環境で直ったことを確かめる
「直した」と言ってよいのは、設定ファイルではなく、実際に動いている環境が出した版が修正版以上だったときだけです。確かめる場所と合格の基準を表にします。
| 場所 | 確かめ方 | 合格の基準 |
|---|---|---|
| 開発機 | which -a node で出た全部の node を判定スクリプトに渡す | 全部 OK(使っていない古い版は消す) |
| CI | ジョブの最初で node check-node-security.mjs を流す | 終了コード0。ログに出た版が修正版以上 |
| Dockerイメージ | ビルドしたイメージの中で判定スクリプトを動かす | 終了コード0 |
| Lambda | 関数の中で process.version をログに出し、デプロイ後のログで見る | 修正版以上 |
| Electronで作られたアプリ | アプリの版からElectronの版を見て、releases.json で同梱のNode.jsの版を引く | アプリ側の発表どおりに更新済み |
判定スクリプトを手順1の一覧と一緒にリポジトリへ入れておけば、次のセキュリティリリースでは「スクリプトを流す→出た場所を上げる→もう一度流す」だけで終わります。
次のセキュリティリリース(2026年11月11日)
Node.jsは、26.x・24.x・22.xの次のセキュリティリリースを2026年11月11日(またはその直後)に出すと予告しています(Wednesday, November 11, 2026 Security Releases)。最も高い深刻度はHighです。10月の修正版に上げた環境も、11月の修正版が出たらこの記事の4段をもう一度回します。予告と発表は、公式のメーリングリスト nodejs-sec でも届きます。
よくある質問
Q. Node.jsの脆弱性の対象かどうかは、どう確かめますか?
使っているNode.jsの版を、その系列の最新のセキュリティリリースと比べます。2026年10月21日のリリースなら、22.23.4・24.22.1・26.12.1より古い版と、20・23・25などサポートが終わった系列の版が対象です。
Q. process.versions.brotli を見れば、Brotliの脆弱性が直ったか分かりますか?
分かりません。Node.jsは修正のコミットだけを取り込んでいて、22.23.3と22.23.4はどちらもBrotliの版の表記が1.1.0です。判定はNode.js自身の版で行います。
Q. 自分のコードでBrotliを使っていなければ、上げなくてもいいですか?
上げてください。Node.js標準のfetchは、応答のcontent-encodingがbrなら自動でBrotliを展開します。また同じリリースにはDNSやTLSの証明書確認の修正も含まれています。
Q. CIで node-version: 24 と書いていれば、自動で修正版になりますか?
なるとは限りません。actions/setup-node の check-latest は既定がfalseで、ランナーにキャッシュされた版が条件に合えばそれを使います。check-latest: true にするか、修正版の番号を明記してください。
Q. Node.js 20を使っています。20の修正版は出ますか?
出ません。Node.js 20は2026年4月30日にサポートが終わりました。サポート中の22・24・26のどれかへ移る必要があります。
Q. VS CodeなどElectronのアプリの中のNode.jsは、Node.jsを上げれば直りますか?
直りません。Electronのアプリは自分の中にNode.jsを持っています。アプリの更新を入れ、Electronのreleases.jsonで同梱のNode.jsの版を確かめてください。
次に読む
Node.jsを入れたばかりで、まず node -v の確かめ方や基本のAPIから押さえたいときはNode.js練習問題集【基礎API編】が入り口になります。サーバー側のセキュリティの考え方全体はバックエンドとは?サーバー・DB・APIの全体像をわかりやすく解説の「セキュリティの基本」の節で整理しています。
