【テクニカル・上級編】MarkdownパーサーにおけるXSS脆弱性とサニタイズ – アプリケーションセキュリティ & 安全な開発防御ガイド

Markdownパーサーの深淵:DOMPurifyが「銀の弾丸」ではない理由と、多層防御のアーキテクチャ

多くの開発者がMarkdownをHTMLに変換する際、markedやmarkdown-itを導入し、その出力をそのままDOMに注入するような「アーキテクチャ上の爆弾」を抱えている。そして、脆弱性診断のレポートで「DOMPurifyを通せ」と指摘され、思考停止して実装を完了させる。これが現代のWebアプリケーションにおけるXSSの温床だ。

今日は、その「表面的な対策」の先にある、攻撃者が狙う盲点と、真に堅牢な変換パイプラインの設計思想について語ろう。

—

1. パーサーの不整合が生む「想定外の解釈」

Markdownパーサーの脆弱性の根源は、仕様(CommonMark)と実装(各ライブラリの拡張機能)の乖離にある。特にunsafe-link(javascript:スキーム)や、属性ベースのインジェクションは、パーサーの正規化プロセスとブラウザのHTMLパースエンジンの解釈の差異を突く。

攻撃者は、パーサーが「安全なテキスト」とみなした断片を、ブラウザが「実行可能なコンテキスト」として再解釈する瞬間を狙っている。例えば、特定のエンコーディングを混ぜたペイロードが、パーサーをすり抜け、DOMPurifyのサニタイズ処理を経てから、ブラウザのパーサーによってデコードされ、JSが起動する。これは、サニタイズの「タイミング」を誤っているから起きる必然的な事象だ。

2. DOMPurifyを「信頼しすぎない」ための設計

DOMPurifyは強力だが、設定なしで使えば「最小限の権限」という原則に反する。デフォルト設定は汎用性を優先しており、あなたのアプリケーションが許可すべきでないタグまで通してしまう可能性がある。

以下のコードは、単にDOMPurifyを通すのではなく、「ビジネスロジックに不要な要素を徹底的に排除する」という思想に基づいたパイプラインの雛形だ。

import DOMPurify from ‘dompurify’;
import { marked } from ‘marked’;

/

  • セキュアなMarkdown変換パイプライン
  • 1. 外部入力をトークナイズし、ASTを生成
  • 2. 許可されたタグ/属性のみをホワイトリスト方式で抽出
  • 3. 必要に応じてコンテキストに応じたエンコーディングを強制

/
const secureMarkdownParser = (markdownInput) => {
// 1. MarkdownをHTMLへ変換
const rawHtml = marked.parse(markdownInput);

// 2. DOMPurifyの厳格な構成
// なぜこれが必要か:デフォルトでは許可されることが多い ‘target’ や ‘rel’ も制限し、
// プロトコルも ‘http’, ‘https’, ‘mailto’ 以外を徹底的に排除する。
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [‘p’, ‘br’, ‘strong’, ‘em’, ‘ul’, ‘li’, ‘code’, ‘pre’], // 必要最小限に絞る
ALLOWED_ATTR: [‘href’, ‘title’], // 必要な属性のみ
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|ftp):|[^:/?#](?:[/?#]|$))/i, // javascript: を遮断
RETURN_TRUSTED_TYPE: true // Trusted Types APIの利用を前提とする
});
};

3. 生成AI時代のプロンプト・インジェクションとの交差点

現在、多くのMarkdownベースのCMSには、AIによる自動生成機能が組み込まれている。ここで発生するのが「間接的プロンプトインジェクション」だ。

AIが生成した「Markdown」自体に、攻撃者が意図した細工(隠しリンク、Base64エンコードされたJSペイロード)が含まれていた場合、それをそのままパーサーに流し込むのは自殺行為に近い。

我々がとるべき防衛戦略は「検証と無害化の分離」だ:

  • 入力のサニタイズ: AIからの出力を受け取った直後に、Markdownパーサーに渡す前にLLM自身のガードレイル機能(入力・出力フィルタリング)で構造チェックを行う。
  • レンダリング時のサンドボックス化: