dangerouslySetInnerHTMLでHTMLを安全に表示する——Next.js × sanitize-htmlの実践ガイド

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でこう書く」という文脈で書かれるので、必然的にフロント側のコード例がまとめやすく、記事として書きやすいという側面もあると思います。

つまりフロントでやるのが主流ではなく、記事にしやすいだけ。

結局どちらがいいかは、明確には言い切れない

Gmail API Microsoft Graph API HTML文字列 Next.js API Route(サーバーサイド) sanitize-html でサニタイズ cid: → 実URL変換 / <style>ブロック除去 サニタイズ済みHTML <EmailBody /> dangerouslySetInnerHTML iframe sandbox(追加の隔離層・推奨)

ライブラリ選定:sanitize-html vs DOMPurify vs 自前実装

HTMLのサニタイズに使えるライブラリを、実務で重要な3つの軸で比較する。

観点sanitize-htmlDOMPurify(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.93.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するか

  1. サニタイズ前のHTMLをブラウザのDevToolsに貼って表示を確認する
  2. サニタイズ後のHTMLも同様に確認する
  3. 両者を比較して、消えている部分を特定する

消えたものがタグなら ADD_TAGS、属性なら ADD_ATTR に追加する。

DOMPurifyのデフォルト許可タグやオプションについて、公式ソースコードで確認

タグ・属性の制御

オプション効果
ALLOWED_TAGS許可タグを完全に置き換える(デフォルトを上書き)
ADD_TAGSデフォルトの許可タグに追加する
FORBID_TAGS特定タグを明示的に禁止する
ALLOWED_ATTR許可属性を完全に置き換える(デフォルトを上書き)
ADD_ATTRデフォルトの許可属性に追加する
FORBID_ATTR特定属性を明示的に禁止する

データ属性・ARIA属性

オプションデフォルト効果
ALLOW_DATA_ATTRtrue
全て許可
data-* 属性の許可/禁止
ALLOW_ARIA_ATTRtrue
全て許可
aria-* 属性の許可/禁止

URI・プロトコル制御

オプション効果
ALLOWED_URI_REGEXP許可するURIスキームの正規表現を指定する
ALLOW_UNKNOWN_PROTOCOLS未知のプロトコルを許可する(セキュリティリスクあり)

出力形式

オプション効果
RETURN_DOM文字列ではなくDOMオブジェクトを返す
RETURN_DOM_FRAGMENTDocumentFragmentを返す
RETURN_TRUSTED_TYPETrusted Typesオブジェクトを返す

動作制御

オプション効果
IN_PLACE入力DOMを直接変更する(高速化)
WHOLE_DOCUMENT<html> / <head> / <body> を含む完全なドキュメントとして処理する
SAFE_FOR_TEMPLATESテンプレートエンジン用にmustache構文等を無害化する
KEEP_CONTENTfalse にすると、除去されたタグの中身のテキストも消える
USE_PROFILES{html: true} / {svg: true} / {mathMl: true} でプロファイル単位で許可する

フック(高度な制御)

フック名タイミング
uponSanitizeElement各要素のサニタイズ時
uponSanitizeAttribute各属性のサニタイズ時
afterSanitizeElements全要素のサニタイズ後
afterSanitizeAttributes全属性のサニタイズ後

基本的にサニタイズで触る可能性が高いのはタグ・属性の制御とURI制御

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タグの一覧があり、実際に bbrdivemh1h6liolulpspanstrongi 等はすべて含まれています。

公式README(ADD_TAGS の説明)

ADD_TAGS: Extend the existing array of allowed tags

「既存の許可リストを拡張する」と明記されており、デフォルト許可リストが存在することが前提の設計です。

サニタイズのテスト

セキュリティ関連のサニタイズ処理にテストを書くのは当然とされています。サニタイズの設定変更で意図せず脆弱性が生まれないことを保証するため

区分観点
正常系安全なHTMLが壊れずに保持されるか太字/リンク/テーブル等が残る
正常系サニタイズ関数自体の基本動作空文字を渡したら空文字が返る
異常系危険な入力が除去されるかscript/onclick/javascript: 等

ただし、見方によっては「DOMPurifyが正しく動くかどうか」をテストしているだけでは?という指摘はありえます。ライブラリの動作テストではない — あくまで「自分たちの設定(SANITIZE_CONFIG)で意図通りの結果になるか」を検証している

なので、このテストは実務的に価値のあるテストです

sanitize-htmlでHTML向けの許可ルールを設計する

sanitize-htmlの設定で最も重要なのは、必要な要素を壊さずに危険な要素を確実に除去するバランスだ。

許可すべき要素

構造タグとして tabletrtdththeadtbodydivspanpbrhr、各レベルの見出し、リスト要素を許可する。

テキスト装飾の biustrongem に加え、font タグ(colorsizeface 属性)もまだ現役なので許可が必要だ。

img タグの srchttps:cid: スキームのみに制限する。a タグの hrefhttps:mailto: のみとし、target="_blank"rel="noopener noreferrer" を強制付与する。

除去すべき要素

scriptiframeobjectembedforminputtextareabutton は無条件で除去する。すべての 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)$/],
  }
}

ここで重要なのは positionabsolute / 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 をホスト側から注入する工夫が要る。セキュリティと利便性のトレードオフを理解した上で判断してほしい。

sandbox なし dangerouslySetInnerHTML のみ <script> → 実行される on* イベント → 発火する CSS → ホストページに影響 Cookie / localStorage → アクセス可 サニタイズ漏れ = 即XSS フェイルセーフなし sandbox あり iframe sandbox + サニタイズ <script> → ブロック on* イベント → ブロック CSS → iframe内に隔離 Cookie / localStorage → 遮断 サニタイズ漏れがあっても ブラウザが最後の砦になる
目次