toml-node: toml.parse()における、長いドット付きキーやテーブルパスを利用した、二次計算量のDoS攻撃

## 概要

`toml.parse()`は、単一の長いドット付きキー、テーブルヘッダー、またはインラインテーブルキーによって、二次計算量(O(n²))のDoS(サービス拒否)攻撃を受ける可能性があります。約100KBの悪意のあるドキュメントを作成すると、数秒間の処理の停止が発生し、約200KBのドキュメントでは、Node.jsプロセスが例外を捕捉できないメモリ不足エラーで完全にクラッシュします。これは、以前に修正されたスタックオーバーフローDoS攻撃(GHSA-82x6-q7mm-w9cf)とは異なる、修正されていないコードパスです。この問題は、配列やインラインテーブルのネストを一切必要とせず、以前の問題を修正した`maxDepth`オプションの影響を受けないことが確認されています。

## 影響を受けるコンポーネント/バージョン

- パッケージ: `toml` (npm)、リポジトリ `BinaryMuse/toml-node`
- 関数: `lib/compiler.js`内の`assign()`, `deepRef()`, `setNestedKey()`
- 監査対象のコードベースにおいて、コミット`1b2ac40`(2026年7月16日)で確認されました。これは、`v5.0.0`タグ(変更ログのみの変更)の1つ前のコミットであり、つまり`v5.0.0`、最新のリリース版に含まれています。すべての以前のリリースに対して網羅的に検証されていませんが、影響を受ける関数は、`v5.0.0`のBigInt/Temporalの変更とは無関係であるため(`CHANGELOG.md`を参照)、この欠陥は、そのリリース以前にも存在していた可能性があります。

## 詳細

`toml.parse(input, options)` (`index.js:5-9`)は、呼び出し元から提供された文字列を、長さや構造の検証なしで`parser.parse()`および`compiler.compile()`に渡します。`lib/compiler.js`内では、3つの異なる関数が、それぞれドット付きキーのセグメントを`for`ループで処理し、**毎回**、完全なパスプレフィックス文字列を最初から再計算します。

- `assign()` (69行目-107行目、81行目の処理): `var intermediatePath =
makeFullPath(keys.slice(0, i + 1));` — トップレベルのドット付きキーの代入、例えば `a.b.c = 1` のような場合に実行されます。
- `deepRef()` (247行目-281行目、252行目の処理): `pathKey(keys.slice(0, i + 1))` という同じパターンを使用します。テーブルヘッダー `[a.b.c]` や、テーブルの配列 `[[a.b.c]]` から `setPath()`/`addTableArray()` を経由して呼び出されます。
- `setNestedKey()` (184行目-202行目、187行目の処理): 同じパターンです。インラインテーブルのドット付きキー、例えば `t = { a.b.c = 1 }` のような場合に実行されます。

`keys.slice(0, i + 1)` は長さ `i` の新しい配列を割り当てます。`pathKey()`/`makeFullPath()` (311行目-317行目) は `.map(quoteDottedString).join(".")` を実行し、これも O(i) です。この処理は、ドット付きキーの `n` 個のセグメントすべてに対して実行されるため、全体の計算量は `O(1 + 2 + ... + n) = O(n²)` となり、`O(n)` ではありません。文字列の計算は、エラーが発生する直前以外では常に実行されるため、早期終了による効率化は期待できません。

ドット付きキーのセグメント数に制限はありません。`src/toml.pegjs` の `dot_ended_table_key_part+` と `inline_dot_key_part+` (122行目、311行目) は、制限のない `+` の繰り返しを表しており、文法の `value` ルールではなく、反復的に解析されます。そのため、既存の `MAX_DEPTH`/`maxDepth` のネスト制限 (`src/toml.pegjs:141-151`、生成された `lib/parser.js:1022` で適用) は、キーの長さに全く影響しません。これは実際に確認されており、`options.maxDepth` を最も厳しい値である `1` に設定しても、16,000 セグメントのキーを使用した場合でも、制限のない場合との間に目に見えるタイミングの差は発生しませんでした。

## 実証

```js
const toml = require('toml');

// nセグメントのドット付きキー: "k0.k1.k2. ... .k{n-1} = 1"
function segKey(n) {
const parts = [];
for (let i = 0; i < n; i++) parts.push('k' + i);
return parts.join('.') + ' = 1\n';
}

// 約100.9 KB の入力 -- 約6.6-6.8秒
toml.parse(segKey(16000));
```

// 約200KBの入力データを使用すると、Nodeプロセスがクラッシュします。
// "FATAL ERROR: Ineffective mark-compacts near heap limit - JavaScript heap out of memory"
toml.parse(segKey(32000));

測定された実行時間 (Node v22、このリポジトリに対する直接実行):

| n (セグメント数) | 入力サイズ | 実行時間 |
|---|---|---|
| 1,000 | 約4.9 KB | 34 ms |
| 8,000 | 約46.9 KB | 1.6 秒 |
| 16,000 | 約100.9 KB | 6.8 秒 |
| 32,000 | 約200 KB | **プロセスがクラッシュ (OOM)** |

`n`を8,000から16,000に倍にすると、実行時間は概ね4.2倍になります。これは、期待されるO(n) (約2倍)ではなく、O(n²)の兆候です。同様のスケールを示す結果は、テーブルヘッダーのバリアント (`[k0. ... .k15999]`, `deepRef()`を使用) とインラインテーブルのバリアント (`t = { k0. ... .k15999 = 1 }`, `setNestedKey()`を使用) でも独立して再現されました。

この問題を、プロパティベースのファジングツール (fast-check、Node上で実行) にも組み込みました。このツールは、文法/コンパイラの12の構造的な次元にわたって、「入力サイズを倍にすると、解析時間が大体2倍を超えない」という条件を自動的にテストします。このツールは、上記の3つのコードパスすべてを、追加の次元を調査しても脆弱性が確認されなかったにもかかわらず、毎回再検出します。このツールは、ご要望に応じて提供可能です。

## 影響

Node.jsはシングルスレッドであるため、同期的なマルチ秒の`parse()`呼び出しは、呼び出し元のプロセスのイベントループ全体をブロックし、そのプロセスが処理している他のすべてのリクエスト/操作を停止させます。最悪の場合、これは単なるハングではなく、約200KBの攻撃者が提供した文字列によって引き起こされる、プロセスを終了させるメモリ不足のエラーです。これは、認証なしで完全なサービス拒否を引き起こします。必要なのは、このライブラリを使用しているアプリケーション (構成のアップロード、APIリクエストのボディ、インポートされたマニフェストなど) によって文字列が解析される能力だけです。

## 推奨される対策

パスのプレフィックスを、ループが各セグメントを処理するたびに、徐々に拡張していく文字列として構築します(各イテレーションで、すでに引用されたセグメントを1つずつ文字列に追加する)。これにより、配列を毎回再スライスしたり、すべてのプレフィックスを最初から再結合したりするよりも効率的です。例えば、`assign()`関数では、以下のようになります。

```js
function assign(node) {
var keys = node.key;
var value = node.value;
var off = node.offset;
if (!Array.isArray(keys)) keys = [keys];
var reduced = reduceValueNode(value);

var target = context;
var pathStr = pathKey(currentPath); // 1回だけ計算される、セグメントごとに計算されるものではない
for (var i = 0; i < keys.length - 1; i++) {
var k = keys[i];
var quotedK = quoteDottedString(k);
var intermediatePath = pathStr ? pathStr + "." + quotedK : quotedK; // O(新しいセグメント)、O(i)ではない
pathStr = intermediatePath;
// ...現在のコードと同じ条件分岐ロジックを使用し、intermediatePathを使用する
target = target[k];
}
// ...
}
```

同様の処理は、`deepRef()`関数と`setNestedKey()`関数にも適用されます。セキュリティ対策として、キーセグメントの総数や、全体の入力サイズに上限を設けることも検討してください。これは、`maxDepth`がネストの深さに上限を設けているのと同じ考え方です。

## 貢献者

SecDim Security Researchによって発見され、報告されました。
secdim.com | @secdim | security@secdim.com

影響を受けるパッケージ:
- npm (JavaScript) toml: 影響を受けるバージョンは5.0.0以下、5.0.1で修正済み。