【テクニカル・上級編】DOMPurifyを用いたクライアントサイドでのHTMLサニタイズ実装 – アプリケーションセキュリティ & 安全な開発防御ガイド

DOMPurifyは「魔法の杖」ではない:クライアントサイド防御の深淵とアーキテクチャの責務

セキュリティの世界において、最も危険なのは「銀の弾丸」という幻想だ。フロントエンド開発者が「DOMPurifyを入れればXSSは完封できる」と盲信した瞬間、それは攻撃者にとっての招待状となる。

今日は、DOMPurifyの表面的な使い方ではなく、ブラウザのDOMツリー構築プロセスと、現代のブラウザエンジンが抱えるパースの曖昧さ、そして「なぜその設定が必要なのか」という低レイヤの防衛哲学について話をしよう。

—

1. なぜブラウザは「汚いHTML」を許容するのか(根本原因)

HTMLパーサーは、悪意ある入力を「可能な限りレンダリングして見せる」ように設計されている。これはWebの歴史的経緯によるものだが、セキュリティの観点では最悪の仕様だ。

攻撃者は、このパースの曖昧さを突く。例えば、タグのonerrorイベントや、javascript:スキームを含むタグ。これらはDOMPurifyがデフォルトで排除するが、真の脅威は「ブラウザによって解釈が異なるエッジケース」にある。

DOMPurifyは、このカオスなHTML入力を一度DOMツリーとして構築し、ホワイトリストに基づきノードを再帰的に走査・除去する。しかし、DOMツリー構築の段階で既にスクリプトが発火してしまうような実装をしていないか?それが最初の監査ポイントだ。

2. 賢明な設計:静的解析から動的フィルタリングへのシフト

DOMPurifyを導入する際、最も多いミスが「動的に生成されたHTMLを、フィルタリングせずにinnerHTMLに流し込む」という単純な処理だ。

// 【NGパターン】
// 入力がDOMPurifyを通っていても、その後のDOM操作のコンテキストが不明瞭
element.innerHTML = DOMPurify.sanitize(userInput);

この実装には重大な欠陥がある。DOMPurify.sanitize()が返す文字列をそのままinnerHTMLに代入することは、ブラウザのパース処理を再度呼び出すことを意味する。ここで、ブラウザ固有の脆弱性(MHTMLの解釈ミス等)を突く特殊なパージングシーケンスが挿入された場合、防御層をすり抜ける可能性がある。

推奨される実装パターン

DOMPurifyを「信頼の起点(Root of Trust)」とするならば、設定は厳格に絞り込むべきだ。

// 【推奨設定:厳格なホワイトリスト運用】
const cleanHTML = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: [‘b’, ‘i’, ‘em’, ‘strong’, ‘a’], // 最小限のタグに限定
ALLOWED_ATTR: [‘href’], // 属性も必要最小限
RETURN_DOM: true, // 文字列ではなくDOMノードを直接返す(パースの再試行を防ぐ)
FORCE_BODY: true // 意図しないフラグメントの混入を防ぐ
});

// 直接appendChildでDOMツリーに統合する
targetElement.appendChild(cleanHTML);

RETURN_DOM: true を指定することで、一度パースされた安全なDOMノードを直接マウントできる。これにより、ブラウザのHTMLパーサーによる「二度目の解釈」の余地を排除している。

—

3. 生成AI時代の新たな脅威:プロンプトインジェクションとの交差点

現在、多くのアプリケーションがLLMとフロントエンドを接続している。ここで発生するのが、「LLMが生成したHTML」という、信頼できない動的コンテンツの爆発的増加だ。

LLMは「もっともらしいHTML」を生成するが、それがDOMPurifyの想定を超える難読化を伴う場合がある。例えば、Base64エンコードされたSVGデータURIや、CSS注入によるUI赤字攻撃(Clickjacking)の派生形だ。

防御のアーキテクチャ設計:CSPとの二重防衛

DOMPurifyだけで完結させようとするな。アプリケーションのアーキテクチャとして、Content Security Policy (CSP) との多層防御を構築せよ。

CSPヘッダーの例:スクリプト実行を厳格に制限
Content-Security-Policy: default-src ‘self’; script-src ‘self’; object-src ‘none’; base-uri ‘self’;

DOMPurifyで「除去しきれなかった何か」が仮にレンダリングされたとしても、script-src 'self' が効いていれば、インラインスクリプトや外部の悪意あるドメインからのコード実行は遮断される。これが、私が提唱する「防御の深度(Defense in Depth)」だ。

—

4. 最後に:エンジニアが持つべき「懐疑的」な視点

CVEを追う際、私は常に「この脆弱性は、どのパーサーの挙動を前提としているのか?」と考える。DOMPurifyもまた、ブラウザの進化に合わせてアップデートされ続ける必要がある。

1. 依存関係の監査: npm audit で満足してはならない。DOMPurifyのバージョン固定と、そのリリースノートに含まれる「bypass」関連の修正履歴を追うこと。
2. 型安全性: TypeScriptで入力をラップし、UntrustedHTML型からTrustedHTML型への変換プロセスをDOMPurifyに集約させること。
3. バイパス実験: 開発段階で、DOMPurifyが除去できない複雑なネストや、制御文字を組み合わせた「ファジング入力」をテストケースに加えよ。

セキュリティはパズルではない。終わりのない「プロセス」だ。ライブラリを盲信せず、常に「もしこのフィルターが突破されたら、次の層でどう止めるか?」を自問自答し続けてほしい。その執着こそが、あなたの作るアプリケーションを真に堅牢なものにする。

コメント

タイトルとURLをコピーしました