1. ホーム
  2. React

【React】dangerouslySetInnerHTML の使い方|HTML文字列をそのまま挿入する方法とXSS対策

Share

React で「HTML の文字列」をそのままタグとして画面に表示したいことがあります。CMS の本文や、Markdown を変換して得た HTML などがその代表です。ところが React は、文字列をそのまま書いても自動でエスケープしてしまうため、タグは文字として表示されてしまいます。これを解決するのが dangerouslySetInnerHTML です。名前のとおり「危険」を含む API なので、この記事では基本の使い方から、なぜ普通の innerHTML ではなくこの形なのか、XSS の危険性、そして DOMPurify を使った安全な使い方までを、React 関数コンポーネント(TSX)のコード付きで解説します。

React が文字列を自動エスケープするので HTML がそのまま表示される

React は JSX の中に埋め込んだ文字列を、既定ですべてエスケープします。これは XSS(クロスサイトスクリプティング)を防ぐための安全な仕組みで、たとえば <b>太字</b> という文字列を表示しようとすると、タグとして解釈されず、そのまま <b>太字</b> という文字が画面に出ます。

Escaped.tsx
export function Escaped() {
  const html = '<b>太字</b>';

  // タグとして描画されず、そのまま文字列として表示される
  // 画面表示: <b>太字</b>
  return <div>{html}</div>;
}

ふだんはこの自動エスケープが安全を守ってくれるのでありがたいのですが、「この文字列は HTML なので、タグとして描画してほしい」というケースでは邪魔になります。CMS(コンテンツ管理システム)から返ってきた記事本文や、Markdown を HTML に変換した結果などがこれにあたります。こうした「HTML 文字列をそのまま DOM に挿入したい」という要求に応えるのが dangerouslySetInnerHTML です。

dangerouslySetInnerHTML の基本の書き方

dangerouslySetInnerHTML は、要素に渡す属性(prop)の1つです。値には { __html: html } という形のオブジェクトを渡します。キーが __html(アンダースコア2つ)である点がポイントで、この決まった形でなければ機能しません。

Article.tsx
type Props = {
  html: string;
};

export function Article({ html }: Props) {
  // __html というキーのオブジェクトを渡すのが決まり
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

こうすると、渡した html 文字列が、その <div> の中身(innerHTML)としてそのまま挿入されます。<b>太字</b> を渡せば、今度は文字ではなく実際に太字として描画されます。値がオブジェクトである理由は次の節で説明しますが、まずは「{{ __html: 文字列 }} という二重の波かっこで渡す」と形で覚えておくとよいでしょう。外側の {} は JSX の式を表し、内側の {} はオブジェクトリテラルです。

なぜ innerHTML ではなく冗長な名前なのか

DOM を直接触るときは element.innerHTML = html と書けば同じことができます。それにもかかわらず React があえて dangerouslySetInnerHTML という長くて仰々しい名前を採用しているのは、「これは危険な操作だ」と書き手にはっきり自覚させるためです。

もし innerHTML という馴染みのある名前だったら、深く考えずに使ってしまい、後述する XSS の穴を作り込みやすくなります。React の公式ドキュメントでも、この API は「銃で自分の足を撃つようなもの(shooting yourself in the foot)」と表現されています。{ __html: ... } というひと手間かかるオブジェクト形式も同じ狙いで、うっかり普通の文字列を渡してしまわないよう、明示的な意思表示を求めているのです。名前とオブジェクト形式そのものが「本当にこれでいいですか?」という警告になっている、と考えると分かりやすいでしょう。

信頼できない文字列を渡すと XSS が起きる

この API が「危険」と呼ばれる理由が XSS です。dangerouslySetInnerHTML に渡した文字列は、そのまま HTML として解釈されます。つまり、その文字列の中に悪意のあるスクリプトが混ざっていれば、それも一緒に DOM へ挿入されてしまいます。とくにユーザーが入力した内容や、外部サービスから取得したデータをそのまま渡すのは危険です。

Danger.tsx
export function Danger() {
  // 例えばコメント欄などから来た「信頼できない」文字列
  // img の onerror に仕込まれたコードが実行されてしまう
  const dirty = '<img src="x" onerror="alert(\'XSS\')" />';

  // このまま渡すと onerror のスクリプトが動く(危険)
  return <div dangerouslySetInnerHTML={{ __html: dirty }} />;
}

この例では、存在しない画像 src="x" の読み込みに失敗した瞬間に onerror の中身が実行され、alert が動きます。実際の攻撃では alert の代わりに Cookie やトークンを盗み出すコードが仕込まれます。<script> タグ自体は innerHTML では実行されませんが、この例のように onerroronload といったイベント属性を使えばスクリプトを走らせることができてしまうため、「script タグを消せば安全」という素朴な対策では防げません。だからこそ、渡す前の無害化が欠かせません。

DOMPurify でサニタイズしてから渡す

信頼できない HTML を扱うときの定番は、サニタイズ(無害化)してから dangerouslySetInnerHTML に渡すことです。サニタイズとは、危険なタグや属性(<script>onerror など)を取り除き、安全な HTML だけを残す処理を指します。自前で正しく実装するのは非常に難しいため、実績のあるライブラリを使うのが基本で、フロントエンドでは DOMPurify が広く使われています。

DOMPurify.sanitize() に汚染された文字列(dirty)を渡すと、危険な部分を取り除いた安全な文字列を返してくれます。その結果を __html に渡します。

SafeArticle.tsx
import DOMPurify from 'dompurify';

type Props = {
  html: string;
};

export function SafeArticle({ html }: Props) {
  // 危険なタグ・属性を取り除いた安全な HTML を得る
  const clean = DOMPurify.sanitize(html);

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

先ほどの <img src="x" onerror="..." />DOMPurify.sanitize() に通すと、onerror 属性が取り除かれ、スクリプトは実行されなくなります。<b><a> のような安全なタグは残るので、見た目を保ったまま危険な部分だけを落とせるのが利点です。毎回のレンダリングで同じ文字列を何度もサニタイズするのが気になる場合は、useMemohtml が変わったときだけ計算するようにすると無駄がありません。なお、サーバーサイドレンダリング(SSR)で使うときは window が無い環境向けの設定が必要になることがあるので、公式ドキュメントの案内を確認してください。

子要素と併用できないなどの制約

dangerouslySetInnerHTML を使うときに戸惑いやすいのが、要素の使い方に関するいくつかの制約です。仕組みを理解しておくと、原因の分からないエラーで悩まずに済みます。

同じ要素に子要素を書くとエラーになる

dangerouslySetInnerHTML は、その要素の中身(children)を丸ごと HTML 文字列で置き換える機能です。そのため、同じ要素に dangerouslySetInnerHTML と JSX の子要素の両方を書くと、「どちらを中身にすべきか」が衝突してしまい、React はエラーを出します。中身は文字列か JSX のどちらか一方にする必要があります。

Conflict.tsx
export function Conflict({ html }: { html: string }) {
  // エラー: dangerouslySetInnerHTML と子要素は同時に指定できない
  return (
    <div dangerouslySetInnerHTML={{ __html: html }}>
      <p>この子要素は書けない</p>
    </div>
  );
}

HTML 文字列とは別に固定の要素も並べたい場合は、外側にラッパーの要素を用意し、その中に dangerouslySetInnerHTML を使う要素と通常の JSX を分けて置きます。

className など他の属性は普通に付けられる

制約があるのはあくまで「中身(children)」の部分だけです。classNamestyleid といった属性は、これまでどおり同じ要素にそのまま付けられます。挿入する HTML にスタイルを当てたいときは、ラッパー要素に className を付けて CSS で子孫セレクタを書くのが定番です。

Styled.tsx
export function Styled({ html }: { html: string }) {
  // className は普通に付けられる
  return (
    <div
      className="article-body"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

挿入した HTML の中では React のイベントが効かない

もう1つ知っておきたいのが、dangerouslySetInnerHTML で挿入した HTML は「ただの DOM」であり、React の管理下にはないという点です。したがって、その中にボタンを入れても React の onClick は割り当てられませんし、中のリンクや要素を React が仮想 DOM として追跡することもありません。あくまで表示専用のコンテンツを流し込む用途と割り切り、操作が必要な UI は通常の JSX で組むのが安全です。

まとめ

dangerouslySetInnerHTML は、React が既定で行う自動エスケープを回避し、HTML 文字列をタグとして DOM に挿入するための API です。書き方は <div dangerouslySetInnerHTML={{ __html: html }} /> で、__html という決まったキーのオブジェクトを渡します。仰々しい名前と冗長なオブジェクト形式は、「これは危険な操作だ」と書き手に自覚させるための設計です。信頼できない文字列をそのまま渡すと、onerror などを通じて XSS が発生するため、外部由来の HTML は必ず DOMPurify.sanitize() のようなサニタイズを通してから渡します。また、同じ要素に子要素を併用できないこと、className などの属性は問題なく付けられること、挿入した HTML には React のイベントが効かないことも押さえておきましょう。

参考ページ