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

目次

HTMLのサニタイズはなぜ難しいのか

ReactでHTML文字列を開発するとき、避けて通れないのが dangerouslySetInnerHTML によるHTML描画だ。

テキストとしてエスケープすればレイアウトが崩壊し、そのまま描画すればXSSの温床になる。

サニタイズの実施箇所を選ぶ

Next.js(App Router)構成では、サニタイズの実施箇所として3つの選択肢がある。

フロントエンドで行う場合

dangerouslySetInnerHTML に渡す直前にサニタイズする方式。

実装箇所が明確で導入は簡単だが、サニタイズライブラリがクライアントバンドルに含まれる。

DOMPurifyなら約20KB(gzip)、isomorphic-dompurify はサーバー側で jsdom を同梱するためさらに重い。

また、APIレスポンス自体は汚染されたHTMLのままなので、将来モバイルアプリなど別のクライアントを追加した場合にサニタイズ処理を二重に実装する必要がある。

バックエンドで行う場合

API Route内でサニタイズし、クリーンなHTMLだけをレスポンスとして返す方式。クライアントのバンドルサイズに影響がなく、どのクライアントから呼んでも安全なHTMLが得られる。

サーバー側の処理コストは増えるが、サニタイズは1リクエストあたり数ミリ秒程度で、実用上ボトルネックにはならない。

両方で行う場合(多層防御)

セキュリティ的には理想に見えるが、2箇所の許可ルールを常に同期し続ける運用コストが発生する。

バックエンド(API Route)で1回サニタイズするのが最もバランスがよい。

フロント側は dangerouslySetInnerHTML を使うコンポーネントを1つに集約し、必ずサニタイズ済みデータしか受け取らないという設計上の制約で安全性を担保する。

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版が内包)
バンドル影響サーバー専用なら無関係サーバー側でjsdom同梱のため重いなし
HTML適性allowedTags / allowedAttributes / allowedStyles で細かく制御可能デフォルトが厳しめ。調整は可能だがsanitize-htmlほど宣言的でない
メンテナンスnpm週間DL 800万超。活発cure53メンテ。セキュリティ研究者コミュニティが厚い自分で脆弱性を追い続ける必要がある

自前実装は選択肢から外すべきだ。

<img src=x onerror=alert(1)> のような基本攻撃ですら、属性値のエスケープやエンコーディングの変種、ネストを正規表現で完全に捕捉するのは不可能に近い。

セキュリティライブラリの自作は暗号の自作と同種のリスクがある。

推奨は sanitize-html をバックエンドで使うパターンだ。 Node.jsにネイティブ対応しており、

HTMLに必要な許可ルールを宣言的に細かく書ける。

DOMPurifyはブラウザのDOMパーサーに依存する設計のため、Node.js環境ではjsdomを介すオーバーヘッドがある。

サーバーサイド専用で使うなら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 → 遮断 サニタイズ漏れがあっても ブラウザが最後の砦になる

導入時に押さえておくこと

sanitize-htmlの設定を本番に入れる前に、以下の点を確認しておくと手戻りが減る。

実での表示テスト。 許可ルールを厳しくしすぎると、リッチエディタなどが生成する複雑なレイアウトや、スタイルが崩れる。

CSPヘッダーの併用。 Content Security Policy で script-src 'self' を設定しておけば、サニタイズ漏れでインラインスクリプトが残っていてもブラウザが実行をブロックする。iframe sandboxと合わせて、サニタイズ・CSP・sandboxの3層で防御するのが堅い構成だ。

目次