ReactでHTML文字列を開発するとき、避けて通れないのが dangerouslySetInnerHTML によるHTML描画だ。
テキストとしてエスケープすればレイアウトが崩壊し、そのまま描画すればXSSの温床になる。

そもそもdangerouslySetInnerHTMLを使用せずにHTMLを描画できないのか
react-html-parser / html-react-parser — HTML文字列をパースしてReactコンポーネントのツリーに変換するライブラリ。dangerouslySetInnerHTML を使わずにHTMLを描画できる。
タグ単位で変換処理をカスタマイズできるので、パース時にサニタイズ的な制御も入れられる。ただし、これ自体はサニタイズライブラリではないので、こちらもサニタイズは必要
サニタイズの実施箇所を選ぶ
Next.js(App Router)構成では、サニタイズの実施箇所として3つの選択肢がある。
フロントエンドで行う場合
dangerouslySetInnerHTML に渡す直前にサニタイズする方式。
実装箇所が明確で導入は簡単だが、サニタイズライブラリがクライアントバンドルに含まれる。
DOMPurifyなら約20KB(gzip)、isomorphic-dompurify はサーバー側で jsdom を同梱するためさらに重い。
バックエンドで行う場合
API Route内でサニタイズし、クリーンなHTMLだけをレスポンスとして返す方式。クライアントのバンドルサイズに影響がなく、どのクライアントから呼んでも安全なHTMLが得られる。
両方で行う場合(多層防御)
セキュリティ的には理想に見えるが、2箇所の許可ルールを常に同期し続ける運用コストが発生する。
dangerouslySetInnerHTMLサニタイズで検索するとDOMPurifyの導入記事が多いです
おそらく理由としては「Reactでこう書く」という文脈で書かれるので、必然的にフロント側のコード例がまとめやすく、記事として書きやすいという側面もあると思います。
つまりフロントでやるのが主流ではなく、記事にしやすいだけ。
結局どちらがいいかは、明確には言い切れない
ライブラリ選定:sanitize-html vs DOMPurify vs 自前実装
HTMLのサニタイズに使えるライブラリを、実務で重要な3つの軸で比較する。

| 観点 | sanitize-html | DOMPurify(isomorphic版) | 自前実装(正規表現) |
|---|---|---|---|
| 安全性 | 高。allowlist方式で堅牢 | 最高。ブラウザDOMパーサー利用でmutation XSSにも対応 | 不可。HTMLの構文解析を正規表現で行うのは原理的に不完全 |
| Node.js対応 | ネイティブ対応 | jsdom必須(isomorphic版が内包) | — |
| HTML適性 | allowedTags / allowedAttributes / allowedStyles で細かく制御可能 | デフォルトが厳しめ。調整は可能だがsanitize-htmlほど宣言的でない | — |
| メンテナンス | npm週間DL 800万超。活発 | cure53メンテ。セキュリティ研究者コミュニティが厚い | 自分で脆弱性を追い続ける必要がある |
自前実装は選択肢から外すべきだ。
<img src=x onerror=alert(1)> のような基本攻撃ですら、属性値のエスケープやエンコーディングの変種、ネストを正規表現で完全に捕捉するのは不可能に近い。
セキュリティライブラリの自作は暗号の自作と同種のリスクがある。
DOMPurifyはブラウザのDOMパーサーに依存する設計のため、Node.js環境ではjsdomを介すオーバーヘッドがある。
サーバーサイド専用で使うならsanitize-htmlの方が筋がよい。
DOMPurify 導入
最新版は3.4.8。セキュリティライブラリなので最新を入れるのが原則。 Snyk
npm install dompurify@^3.4.8
こちら型ファイルは同梱されているようで、別で型のライブラリはインストールしなくていいみたい
npm install dompurify@^3.4.8 で入れると、package.json には ^3.4.8 と書かれる。これは 3.4.9 や 3.4.10 などのパッチアップデートを自動で許容する記法。npm install dompurifyだけでも最新バージョンがインストールされ同じ挙動です
// lib/sanitize.ts
import DOMPurify from 'dompurify';
const SANITIZE_CONFIG = {
ADD_TAGS: ['center', 'font'],
ADD_ATTR: [
'cellpadding', 'cellspacing', 'bgcolor', 'align', 'valign',
'border', 'color', 'size', 'face', 'width', 'height',
'background', 'target',
],
};
export function sanitizeEmailHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, SANITIZE_CONFIG);
}デフォルトの設定で十分か
メリット
- DOMPurifyのセキュリティチームが継続的にメンテナンスしている
- 新しい攻撃ベクトルが発見されたらライブラリ更新で自動対応される
- 設定がシンプルで、必要な差分だけ
ADD_ATTR/FORBID_TAGSなどで調整すればよい
(sanitize-htmlは最初から「何を許可するか」を全部自分で書く設計なので、設定量は多いが想定外の除去は起きにくい、DOMPurifyは「少ない設定で安全にできる」、sanitize-htmlは「細かく制御できる」。どちらが便利かは用途次第)
デメリット
- 不要なタグも許可される
表示崩れの調査手順
「タグ」と「属性(ATTR)」のどちらをADDするか
- サニタイズ前のHTMLをブラウザのDevToolsに貼って表示を確認する
- サニタイズ後のHTMLも同様に確認する
- 両者を比較して、消えている部分を特定する
消えたものがタグなら ADD_TAGS、属性なら ADD_ATTR に追加する。
DOMPurifyのデフォルト許可タグやオプションについて、公式ソースコードで確認
タグ・属性の制御
| オプション | 効果 |
|---|---|
ALLOWED_TAGS | 許可タグを完全に置き換える(デフォルトを上書き) |
ADD_TAGS | デフォルトの許可タグに追加する |
FORBID_TAGS | 特定タグを明示的に禁止する |
ALLOWED_ATTR | 許可属性を完全に置き換える(デフォルトを上書き) |
ADD_ATTR | デフォルトの許可属性に追加する |
FORBID_ATTR | 特定属性を明示的に禁止する |
データ属性・ARIA属性
| オプション | デフォルト | 効果 |
|---|---|---|
ALLOW_DATA_ATTR | true全て許可 | data-* 属性の許可/禁止 |
ALLOW_ARIA_ATTR | true全て許可 | aria-* 属性の許可/禁止 |
URI・プロトコル制御
| オプション | 効果 |
|---|---|
ALLOWED_URI_REGEXP | 許可するURIスキームの正規表現を指定する |
ALLOW_UNKNOWN_PROTOCOLS | 未知のプロトコルを許可する(セキュリティリスクあり) |
出力形式
| オプション | 効果 |
|---|---|
RETURN_DOM | 文字列ではなくDOMオブジェクトを返す |
RETURN_DOM_FRAGMENT | DocumentFragmentを返す |
RETURN_TRUSTED_TYPE | Trusted Typesオブジェクトを返す |
動作制御
| オプション | 効果 |
|---|---|
IN_PLACE | 入力DOMを直接変更する(高速化) |
WHOLE_DOCUMENT | <html> / <head> / <body> を含む完全なドキュメントとして処理する |
SAFE_FOR_TEMPLATES | テンプレートエンジン用にmustache構文等を無害化する |
KEEP_CONTENT | false にすると、除去されたタグの中身のテキストも消える |
USE_PROFILES | {html: true} / {svg: true} / {mathMl: true} でプロファイル単位で許可する |
フック(高度な制御)
| フック名 | タイミング |
|---|---|
uponSanitizeElement | 各要素のサニタイズ時 |
uponSanitizeAttribute | 各属性のサニタイズ時 |
afterSanitizeElements | 全要素のサニタイズ後 |
afterSanitizeAttributes | 全属性のサニタイズ後 |
基本的にサニタイズで触る可能性が高いのはタグ・属性の制御とURI制御
- README
https://github.com/cure53/DOMPurify - 設定の型定義
https://github.com/cure53/DOMPurify/blob/main/src/config.ts - 公式Wiki(デフォルト許可リストの説明ページ)
Default TAGs ATTRIBUTEs allow list & blocklist
DOMPurify’s default behaviour permits the allow-listed tags and attributes inside the input. Anything not on the allow-list is implicitly removed.
ADD_TAGS は既存の許可リストを拡張するオプションだと明記されています。
公式ソースコード src/tags.ts
ここにデフォルト許可されるHTMLタグの一覧があり、実際に b, br, div, em, h1〜h6, li, ol, ul, p, span, strong, i 等はすべて含まれています。
公式README(ADD_TAGS の説明)
ADD_TAGS: Extend the existing array of allowed tags
「既存の許可リストを拡張する」と明記されており、デフォルト許可リストが存在することが前提の設計です。
サニタイズのテスト
セキュリティ関連のサニタイズ処理にテストを書くのは当然とされています。サニタイズの設定変更で意図せず脆弱性が生まれないことを保証するため
| 区分 | 観点 | 例 |
|---|---|---|
| 正常系 | 安全なHTMLが壊れずに保持されるか | 太字/リンク/テーブル等が残る |
| 正常系 | サニタイズ関数自体の基本動作 | 空文字を渡したら空文字が返る |
| 異常系 | 危険な入力が除去されるか | script/onclick/javascript: 等 |
ただし、見方によっては「DOMPurifyが正しく動くかどうか」をテストしているだけでは?という指摘はありえます。ライブラリの動作テストではない — あくまで「自分たちの設定(SANITIZE_CONFIG)で意図通りの結果になるか」を検証している
なので、このテストは実務的に価値のあるテストです
sanitize-htmlでHTML向けの許可ルールを設計する
sanitize-htmlの設定で最も重要なのは、必要な要素を壊さずに危険な要素を確実に除去するバランスだ。
許可すべき要素
構造タグとして table、tr、td、th、thead、tbody、div、span、p、br、hr、各レベルの見出し、リスト要素を許可する。
テキスト装飾の b、i、u、strong、em に加え、font タグ(color、size、face 属性)もまだ現役なので許可が必要だ。
img タグの src は https: と cid: スキームのみに制限する。a タグの href は https: と mailto: のみとし、target="_blank" と rel="noopener noreferrer" を強制付与する。
除去すべき要素
script、iframe、object、embed、form、input、textarea、button は無条件で除去する。すべての on* イベントハンドラ属性、javascript:・data:・vbscript: スキームのURLも同様だ。
<style> ブロック(タグ)は除去するか、juice 等のライブラリでインラインスタイルに変換してから除去する。CSSルールがホストアプリのレイアウトを破壊するのを防ぐためだ。
style属性の値を制御する
インライン style 属性は許可するが、値レベルでホワイトリスト制御する。sanitize-htmlの allowedStyles オプションで、プロパティごとに正規表現でバリデーションをかけられる。
allowedStyles: {
'*': {
'color': [/^#[0-9a-fA-F]{3,6}$/, /^rgb\(/],
'background-color': [/^#[0-9a-fA-F]{3,6}$/, /^rgb\(/],
'text-align': [/^(left|right|center|justify)$/],
'font-size': [/^\d+(px|pt|em|%)$/],
'font-family': [/.+/],
'width': [/^\d+(px|%|em)$/],
'height': [/^\d+(px|%|auto)$/],
'padding': [/^[\d.]+(px|%|em)(\s+[\d.]+(px|%|em)){0,3}$/],
'margin': [/^[\d.]+(px|%|em|auto)(\s+[\d.]+(px|%|em|auto)){0,3}$/],
'border': [/.+/],
'display': [/^(block|inline|inline-block|none|table|table-row|table-cell)$/],
'vertical-align': [/^(top|middle|bottom|baseline)$/],
}
}
ここで重要なのは position(absolute / fixed)と z-index を許可しないことだ。
iframe sandboxでサニタイズ漏れに備える
sanitize-htmlの許可ルールを適切に設定すれば実用上十分だが、サニタイズ処理にバグがあった場合のフェイルセーフとして、描画側に追加の隔離層を設けることを推奨する。
GmailやOutlookが採用しているのが、<iframe> の sandbox 属性による隔離だ。
export function body({ sanitizedHtml }: Props) {
return (
<iframe
srcDoc={sanitizedHtml}
sandbox=""
title="本文"
style={{
width: '100%',
border: 'none',
overflow: 'hidden',
}}
/>
);
}
sandbox 属性を値なし(空文字列)で指定すると、iframe内でのスクリプト実行、フォーム送信、ポップアップ、親ページへのアクセスがすべてブロックされる。
万が一サニタイズをすり抜けた <script> タグがあっても実行されない。
この方式の課題はiframe内のコンテンツ高さをホスト側で検知する必要がある点だ。
iframe内に ResizeObserver を仕込んで postMessage で高さを通知する方法が定番だが、sandbox を空にするとスクリプトが動かないため、sandbox="allow-scripts" を付与するか、MutationObserver をホスト側から注入する工夫が要る。セキュリティと利便性のトレードオフを理解した上で判断してほしい。