🧼

雰囲気理解だったXSS対策をDOMPurifyで“実務レベル”に引き上げた話

に公開

導入:DOMPurifyを導入することになった背景

先日、XSS対策として dangerouslySetInnerHTML で描画している箇所をサニタイズする必要がありました。管理者側が作成したHTMLのみを描画する箇所だったので後手に回っていましたが、対策するに越したことはないです。

フロントエンド側の対応として DOMPurify というライブラリが良さそうだったので、実例とTipsをまとめます。

https://github.com/cure53/DOMPurify

XSSとは何か(雰囲気理解の一歩先へ)

XSS(クロスサイトスクリプティング)は、悪意あるスクリプトが本来安全であるべきページ上で実行されてしまう脆弱性です。たとえば、ユーザーが投稿したHTMLに onerror 属性付きの画像タグが紛れていれば、描画された瞬間に任意のJavaScriptコードを走らせることができます。Cookieが抜かれたり、APIが勝手に叩かれたり…考えるだけで怖いです。

ユーザーがHTMLを投稿できる場合、“ユーザーが悪意のあるスクリプトを書かないだろう”という希望的観測は危険です。問題は、攻撃コードが普通のHTMLに見えてしまうこと。見た目の素朴さが逆に罠になります。

DOMPurifyを使うと、許可したものだけ通す=ホワイトリスト方式 で簡単に対策できます。

DOMPurifyは何をして、何をしてくれないのか

DOMPurifyは “HTMLを無害化(sanitize)するフィルター” です。内部的には、許可されたタグや属性だけを残し(ホワイトリスト)、それ以外は除去または変換します。

ただし、これは絶対防御ではありません。

  • dangerouslySetInnerHTML を使う時点で、Reactの“安全設計”を一部バイパスしている
  • 許可範囲を広げすぎると攻撃面が再び開いてしまう
  • そもそもHTMLにスクリプト実行前提の設計なら根本から見直しが必要

ReactアプリにDOMPurifyを導入する

最小例:

import DOMPurify from 'dompurify';

export const SafeHtml = ({ html }: { html: string }) => {
  const sanitized = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
};

上記のように毎回コンポーネント内で書くより、sanitize専用のラッパー関数を用意すると管理しやすいと思います。

Next.jsでのラッパー関数(SSR/CSRの前提と注意)

ベース実装(クライアント限定でDOMPurifyを実行)

私のケースではNext.jsを用いたプロジェクト(しかもPages Router使用)なので、まずは以下のようなラッパーを考えました。

export const sanitizeHtml = (dirty: string): string => {
  // クライアントサイドでのみDOMPurifyを実行
  if (typeof window !== 'undefined') {
    const DOMPurify = require('dompurify');
    return DOMPurify.sanitize(dirty);
  }
  // サーバーサイドでは生のHTMLを返す(Next.jsのSSRでは使用されない想定)
  return dirty;
}

リスク:将来の改修でSSR経路から誤って呼ばれると“生HTMLをそのまま出す”事故になります。

実務でのおすすめパターン

1) isomorphic-dompurify を使う(SSR/CSR同一インターフェース)
isomorphic-dompurifyは、サーバーでもクライアントでもDOMPurifyを同じように使えるようにしてくれるラッパーです。これを噛ませれば、呼び出されるのがどっちサイドなのかを気にしなくてよくなります。

// npm i isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify';

export const sanitizeHtml = (dirty: string): string => {
  return DOMPurify.sanitize(dirty);
};

2) SSRで呼ばれてもフェイルセーフ(最小限のエスケープ)

const escapeHtml = (s: string) =>
  s.replace(/&/g, '&amp;')
   .replace(/</g, '&lt;')
   .replace(/>/g, '&gt;');

export const sanitizeHtml = (dirty: string): string => {
  if (typeof window !== 'undefined') {
    const DOMPurify = require('dompurify');
    return DOMPurify.sanitize(dirty);
  }
  return escapeHtml(dirty);
};

3) 設計で封じる(型・lint・責務分離)

  • SSR層からは sanitizeHtml を import できない構成
  • no-restricted-imports でSSR層からのインポート禁止
  • 'use client'SafeHtml コンポーネントに限定

SafeHtml のNext.js版(クライアント限定)

'use client';
import DOMPurify from 'dompurify';
import React from 'react';

export const SafeHtml: React.FC<{ html: string }> = ({ html }) => {
  const sanitized = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
};

DOMPurifyのオプション設定と、実務で判断に迷うポイント

DOMPurifyはデフォルトでも“最低限の安全ライン”を守れますが、実務ではどこまで許可するかの調整が要ります(= ホワイトリストをどう設計するか)。

以下に各オプションの意味と検討事項をまとめます。

ALLOWED_TAGS

どんなオプション?:許可するタグのホワイトリストです。列挙したタグだけ通し、それ以外は落とします。

デフォルトは安全寄りですが、Markdown/リッチテキストで必要なタグを最小限足す形が無難です。

  • 慎重に:<style>(挙動変化), <iframe>(外部埋め込み), <script>(論外)

例:

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['b', 'strong', 'i', 'em', 'p', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
});

ALLOWED_ATTR

どんなオプション?:許可する属性のホワイトリストです。列挙した属性だけ通し、それ以外は落とします。

属性はタグより攻撃に直結しやすいので絞り込みが重要。特に イベント属性onerror/onload/onclick)は絶対に許可しない。

MarkdownやWYSIWYGで必要になりやすいのは:href / target / rel など。rel="noopener noreferrer" の付与はセキュリティとパフォーマンス両面で有益です(必要ならレンダリング後に強制上書きでも可)。

ALLOW_DATA_ATTR

どんなオプション?data-* 属性をホワイトリストに一括追加するかどうかを制御します(デフォルトは不許可)。

data-* は便利ですが、アプリ側が値をそのままロジックに渡す設計だと 意図しない挙動 の温床に。必要な場合だけ最小限で許可し、値の扱いには検証・制限を。

KEEP_CONTENT

どんなオプション?:禁止タグを除去した際に“タグの中身(テキスト)だけ残す”かどうかを制御します。

便利だが、危険タグ内のテキストが“謎の文字列”として残ることがあり、UIの混乱やログ解析の難度を上げる。使うなら危険タグ検出のロギングとセットで。

SAFE_FOR_TEMPLATES

どんなオプション?:テンプレートエンジン向けの互換モードです。二重サニタイズや解釈ミスを避けます。

Reactでは通常不要(React自体がデフォルトエスケープを持つため)。


具体例:悪意のあるHTML → サニタイズ後のHTML

実際に“怪しいHTML”がどのように無害化されるかが以下になります。

設定(例)

const sanitized = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['p', 'a', 'img', 'code', 'pre', 'ul', 'ol', 'li', 'b', 'i', 'strong', 'em'],
  ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt'],
  // ALLOW_DATA_ATTR: false // デフォルトは不許可
});

悪意のある(かもしれない)HTML

<p>こんにちは<script>alert('xss')</script></p>
<img src="x" onerror="fetch('/steal')">
<a href="javascript:alert(1)" target="_blank">Click me</a>
<iframe src="https://evil.example"></iframe>
<div data-note="<!-- payload -->">note</div>

サニタイズ後(期待される出力イメージ)

<p>こんにちは</p>
<img src="x">
<a target="_blank">Click me</a>
<div>note</div>
  • <script> タグは丸ごと削除
  • 画像の onerror は削除(src は残る)
  • href="javascript:..." は削除され、リンクはただのテキスト化
  • <iframe> は許可していないため削除
  • data-* はデフォルト不許可なので削除

実際の結果はオプションやDOMPurifyのバージョンで多少異なる可能性がありますが、ホワイトリストにないものは通らないという大原則は共通です。

導入後に何が変わったか(効果と運用の楽さ)

  • セキュリティレビューの通過がスムーズに:“自前実装の抜け”の恐怖が減った
  • コードレビュー観点が明確に:ホワイトリストの変更差分に集中できる
  • 将来の仕様変更耐性:未知のタグ/属性は基本落ちるため、ブラックリストより楽

まとめ:明日からXSSに怯えないための最低ライン

  • 基本姿勢は ホワイトリスト方式(タグも属性も)
  • Next.jsでは SSR経路の事故を設計で封じるisomorphic化フェイルセーフのいずれかを
  • オプションは 必要最小限ログで運用する

おわりに

脆弱性対応としてDOMPurifyを入れてみたときのまとめでした。徳丸本で読んだものの、やや雰囲気理解だったXSS対策を具体的に実装するのは学びになります。

導入してみると(DOMPurifyの出来が良すぎて)割に実装はシンプルで、検討するポイントもほぼホワイトリストの設計のみ(すばらしいーーー!)。

最後に半歩先に議論を進めるのならば、この生成AI時代、中長期的にはHTMLよりもMarkdownを入力するようなニーズの方が多くなりそうな気がしています。この移行が進むと、HTMLで脅威だった攻撃の多くがつぶされていくのかなーと(Markdownのパーサー次第では全然怖いですが……)。

おわりっ。

Discussion