空の private が共有キャッシュを開ける — undici CVE-2026-13697

キャッシュの安全性は、たいてい「入れてよいものだけを入れる」という形で書かれる。上流が Cache-Control: private と言ってきたら共有キャッシュには置かない。それで守れているつもりになる。

守れているかどうかを決めるのは、壊れたヘッダが来たときにどちらへ倒れるかである。壊れていたら「安全側=入れない」に倒れるのか、それとも「該当なし=入れてよい」に倒れるのか。ここが逆だと、正しく private と言っている上流だけが守られて、言い方を間違えた上流は素通りする。

Node.js の HTTP クライアント undici の CVE-2026-13697 は、そのずれの例だ。private の判定はちゃんと存在する。呼ばれてもいる。それでも守れていない。

影響範囲は 7.0.0 以上 7.29.0 未満8.0.0 以上 8.9.0 未満、修正版は 7.29.08.9.0 である。

この記事では、何がどう壊れるのかを仕組みから読み解き、最後に自分の依存ツリーが該当するかを判定する依存なしのスクリプトを置く。

何が起きるのか

undici には cache interceptor がある。HTTP の応答をキャッシュして、同じキャッシュキーの要求が来たら再利用する仕組みだ。ここには2つのモードがあって、shared は複数の呼び出し元でキャッシュを共有し、private は共有しない。

ベンダーの GitHub Security Advisory GHSA-4cwx-7wf7-3272 は、2つの欠陥を挙げている。

1つ目は情報漏えいである。 上流が返す Cache-Controlprivate ディレクティブが壊れた形で来たとき、undici がそれを見落とす。advisory が挙げる形は private=""、つまり「対象ヘッダを指定した private」の書式でありながら中身が空という状態だ。

private には2つの書き方がある。修飾なしの private は「応答まるごと共有キャッシュ禁止」で、修飾つきの private="Set-Cookie" は「そのヘッダだけ共有キャッシュ禁止」を意味する。private="" は後者の書式で対象を1つも挙げていない、いわば degenerate な形だ。

ここで「対象が空なのだから禁止するものは無い=共有キャッシュに入れてよい」と読むと、応答まるごとが既定の共有キャッシュへ入る。そして同じキャッシュキーで後から来た別の呼び出し元に、その応答がそのまま配られる。

advisory と NVD の記述はどちらも、漏れる対象に応答本文だけでなく Set-Cookie を含むヘッダが入ると明記している。利用者ごとに違うはずの応答が、別の利用者に渡る。

2つ目は可用性である。 修飾なしの private と修飾つきの private が1つの Cache-Control に混在していると、cache-control のパーサで未捕捉の TypeError が起きる。その要求は失敗し、呼び出し側のエラー処理次第ではプロセスごと落ちる

成立条件は3つそろったときだけ

advisory は条件を明示している。

  1. cache interceptor が shared モードで有効になっていること(既定の構成を含む)
  2. 上流が壊れた Cache-Control を返すこと
  3. 後続の要求が同じキャッシュキーに当たること

つまり攻撃者が単独で条件を作れるわけではなく、上流の応答ヘッダに依存する。ここを読み違えると緊急度の見積もりを間違える。

逆に言えば、cache interceptor を使っていなければこの2つはどちらも成立しない。これは明示的に組み込む機能なので、まずそこを切り分けるのが実務的な順番だ。

CVSS が評価者で割れている

このCVEで気をつけたいのは、スコアが1つに決まっていないことである。

評価元 スコア ベクタ
ベンダー advisory(GHSA-4cwx-7wf7-3272) 7.4 High CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:H
NVD API が返す主評価 7.4 High 同上
NVD 詳細ページに併記される評価 9.1 Critical AV:N/AC:L/...

割れているのは AC(攻撃条件の複雑さ)だ。上の「3つそろったときだけ」を条件の複雑さとして数えれば AC:H になり、既定構成で成立しうることを重く見れば AC:L になる。どちらの読みにも理屈がある。

片方だけを引いて断定しないほうがいい。 社内の脆弱性管理でスコアを閾値に使っているなら、7.4 と 9.1 では扱いが変わる組織が多い。参照元をセットで記録しておくと、後から「なぜこの緊急度にしたのか」を説明できる。

自分が該当するかを判定する

package.json に書いてあるバージョン範囲は、実際に入っているものと一致しない。ロックファイルを更新していなければ範囲を直しても実体は変わらないし、依存の奥で別バージョンが同居していることもある。

見るべきは node_modules の実体だ。依存なしで書ける。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// check-undici.js — node check-undici.js [プロジェクトのパス]
const fs = require('fs');
const path = require('path');

const FIXED = [
{ major: 7, fixed: [7, 29, 0] },
{ major: 8, fixed: [8, 9, 0] },
];

function parse(v) {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v));
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function lt(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i];
}
return false;
}

function judge(version) {
const v = parse(version);
if (!v) return { verdict: 'unknown', note: 'バージョンを解釈できない' };
const rule = FIXED.find((r) => r.major === v[0]);
if (!rule) return { verdict: 'out-of-range', note: '7系・8系のいずれでもない' };
return lt(v, rule.fixed)
? { verdict: 'AFFECTED', note: `${rule.fixed.join('.')} 以降へ更新する` }
: { verdict: 'ok', note: '修正版以降' };
}

// node_modules を辿って undici の実体を全部拾う(多重インストールを見落とさない)
function walk(dir, found, depth) {
if (depth > 8) return found;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return found;
}
for (const e of entries) {
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
const full = path.join(dir, e.name);
if (e.name === 'undici') {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(full, 'package.json'), 'utf8'));
if (pkg.name === 'undici') found.push({ version: pkg.version, at: full });
} catch {}
}
if (e.name === 'node_modules' || !e.name.startsWith('.')) walk(full, found, depth + 1);
}
return found;
}

const root = path.resolve(process.argv[2] || '.');
const found = walk(path.join(root, 'node_modules'), [], 0);

if (found.length === 0) {
console.log('undici は node_modules に見つからなかった(直接の依存としては未使用)');
process.exit(0);
}

let affected = 0;
for (const f of found) {
const r = judge(f.version);
if (r.verdict === 'AFFECTED') affected++;
console.log(`${r.verdict.padEnd(12)} undici@${f.version} ${r.note}`);
console.log(` ${path.relative(root, f.at)}`);
}
console.log(`\n実体 ${found.length} 件 / 該当 ${affected} 件`);
process.exit(affected > 0 ? 1 : 0);

該当があれば終了コード 1 を返すので、CI にそのまま置ける。

出力はこうなる。

1
2
3
4
AFFECTED     undici@7.14.2  7.29.0 以降へ更新する
node_modules/undici

実体 1 件 / 該当 1

バージョンが該当しても、それだけでは危険とは限らない。 上に書いたとおり cache interceptor を shared モードで使っていることが前提なので、判定が赤になったら次に自分のコードで interceptor を組み込んでいるかを見る。使っていなければ、通常の依存更新のサイクルで上げればよい。

更新できない期間にやること

advisory が挙げる回避策は3つある。

1つ目、利用者ごとに異なる応答に共有キャッシュを使わない。 認証後のページ、個人設定、Set-Cookie を伴う応答が対象だ。

2つ目、cache interceptor の type'private' にする。 この欠陥は shared モードでのみ成立するので、これが最も直接的な緩和になる。

3つ目、問題のある上流オリジンについてキャッシュそのものを無効化する。 上流のヘッダが直らないなら、そこだけ外す。

なお、ディストリが配布する nodejs パッケージ側でこのCVEがどう扱われるかは、この記事を書いた 2026年8月21日時点では Ubuntu USN / Red Hat RHSA / Debian security tracker のいずれでも確認できていない。Node.js 本体が同梱する undici が対象になるかも同様である。確認できていないことは、確認できていないと書いておく。 ここを埋めたくなる気持ちが、存在しないパッチ番号を生む。

学び

この欠陥の形は undici に固有ではない。壊れた入力をどちらへ倒すかという設計判断であり、パーサを書く場所ならどこにでも同じ穴が空く。

private="" は仕様上ありえない書き方ではなく、単に対象を挙げていないだけの private である。それを「禁止対象ゼロ=制限なし」と読むか「意図は private なのだから安全側」と読むかで、結果が正反対になる。

自分のコードで似た判定を書いているなら、一度探してみるといい。「該当なし」と「制限なし」を同じ分岐に落としている場所は、たいてい1つや2つある。

掲載しているCVEの一覧と対応状況は CVE Watch に置いてある。


空の private が共有キャッシュを開ける — undici CVE-2026-13697
https://blog.hashito.biz/2026/08/21/undici-cve-2026-13697-cache-interceptor-private/
著者
hashito
作成日
2026年8月21日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。