dangerouslySetInnerHTML とは、HTMLの文字列をそのまま要素の中身として表示するための React の属性で、{ __html: 文字列 } という形のオブジェクトを渡して使います。 表示する文字列に利用者が入力した内容が混ざる場合は、DOMPurify などでサニタイズ(動いてしまう部分を取り除く処理)をしてから渡すのが基本です。

この記事では、書式と実際のエラー文を確かめたうえで、scriptタグや img の onerror など10種類の文字列を DOMPurify に通し、通す前と後の出力を一覧表にしました。さらに、同じ文字列を Chromium 上の React で表示して、どれが実際に動くかも確かめています。設定例、サーバー側での動かし方、ほかのライブラリとの使い分けまで、コピーして使える完成形のコードで順に見ていきます。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0・react-dom 19.3.0 / DOMPurify 3.4.16 / jsdom 30.1.1 / isomorphic-dompurify 4.4.0 / html-react-parser 6.1.8 / react-markdown 10.1.0。ブラウザでの動作は Playwright 1.56.0 の Chromium で確かめました。

dangerouslySetInnerHTML の早見表

やりたいこと 書き方 補足
HTML文字列を表示する <div dangerouslySetInnerHTML={{ __html: html }} /> 値は __html キーを持つオブジェクト
文字として表示する(タグを働かせない) <div>{html}</div> React が自動でエスケープする
利用者の入力を含むHTMLを表示する DOMPurify.sanitize(html) の結果を __html に渡す 動く部分が取り除かれる
使えるタグを絞る DOMPurify.sanitize(html, { ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] }) 許可リスト方式
別タブで開くリンクに rel を付ける DOMPurify.addHook("afterSanitizeAttributes", ...) フックで属性を足す
サーバー側でもサニタイズする isomorphic-dompurify を import する Node.js とブラウザの両方で同じ書き方
HTMLを React の要素に変換する html-react-parser の parse(html) 一部を自作コンポーネントに差し替えられる
Markdown を表示する react-markdown 既定では生のHTMLを文字として表示する

よく出るエラー文と原因は次のとおりです(React 19.3.0 で出力したもの)。

エラー文 原因
Can only set one of `children` or `props.dangerouslySetInnerHTML`. 子要素と同時に指定した
`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. 文字列をそのまま渡した、またはキー名が違う
img is a self-closing tag and must neither have `children` nor use `dangerouslySetInnerHTML`. 中身を持てない要素に指定した
DOMPurify.sanitize is not a function サーバー側(window がない場所)で dompurify をそのまま使った

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「dangerouslySetInnerHTMLの使い方とサニタイズ」

文字列を {} に入れるとエスケープされることを確かめる

React は、{} に入れた文字列を文字として表示します。タグの記号は自動で置き換えられる(エスケープされる)ので、HTMLとしては働きません。これは、入力された内容がそのまま動いてしまうことを防ぐための、React の標準の動きです。

renderToString(コンポーネントをHTML文字列に変換する関数)で、2つの書き方の出力を比べます。

// escape.tsx
import { renderToString } from "react-dom/server";

const html = '<b>太字</b>と<a href="https://example.com">リンク</a>';

console.log("A:", renderToString(<div>{html}</div>));
console.log("B:", renderToString(<div dangerouslySetInnerHTML={{ __html: html }} />));

実行の準備は次のとおりです。tsx は、TypeScript と JSX のファイルをそのまま実行できるツールです(今回は 4.23.15)。tsconfig.json には "jsx": "react-jsx" を指定しています。

npm install react react-dom
npm install -D tsx
npx tsx escape.tsx
A: <div>&lt;b&gt;太字&lt;/b&gt;と&lt;a href=&quot;https://example.com&quot;&gt;リンク&lt;/a&gt;</div>
B: <div><b>太字</b>と<a href="https://example.com">リンク</a></div>
書き方 出力 画面での見え方
A:{html} タグの記号が &lt; などに置き換わる タグが文字のまま見える
B:dangerouslySetInnerHTML 文字列がそのままHTMLになる 太字とリンクとして表示される

「タグが文字のまま画面に出てしまう」ときは A の書き方になっています。 HTMLとして表示したい場合に使うのが B の dangerouslySetInnerHTML です。

dangerouslySetInnerHTML の書式とエラー文

基本の書式

渡す値は、文字列ではなく __html というキーを持つオブジェクトです。アンダースコアは2つです。

const markup = { __html: "<p>お知らせの本文</p>" };
return <div dangerouslySetInnerHTML={markup} />;

JSXの中に直接書くと、波かっこが二重になります。外側は「JavaScriptの式を書く」ための波かっこ、内側はオブジェクトの波かっこです。

<div dangerouslySetInnerHTML={{ __html: html }} />

React の公式ドキュメントは、この { __html } のオブジェクトをHTMLを作る場所のできるだけ近くで作ることをすすめています。HTMLとして扱う値がどれなのかがコード上ではっきりし、想定した変数だけが渡るようになるためです。後の章で作る SafeHtml コンポーネントは、この考え方に沿っています。

書式を間違えたときのエラー文

よくある書き間違いを5つ並べて、実際のエラー文を出力しました。

// errors.tsx
import { renderToString } from "react-dom/server";

const html = "<b>太字</b>";

function show(label: string, fn: () => string) {
  try {
    console.log(label, "=>", fn());
  } catch (e) {
    console.log(label, "=> Error:", (e as Error).message);
  }
}

// 1. children と同時に指定
show("children併用", () =>
  // @ts-expect-error 再現のため
  renderToString(<div dangerouslySetInnerHTML={{ __html: html }}>本文</div>)
);
// 2. 文字列をそのまま渡す
show("文字列を直接", () =>
  // @ts-expect-error 再現のため
  renderToString(<div dangerouslySetInnerHTML={html} />)
);
// 3. キー名の打ち間違い(_が1つ)
show("キー名が_html", () =>
  // @ts-expect-error 再現のため
  renderToString(<div dangerouslySetInnerHTML={{ _html: html }} />)
);
// 4. __html が undefined
show("__htmlがundefined", () =>
  renderToString(<div dangerouslySetInnerHTML={{ __html: undefined as unknown as string }} />)
);
// 5. 中身を持てない要素に指定
show("imgに指定", () =>
  renderToString(<img dangerouslySetInnerHTML={{ __html: html }} />)
);
children併用 => Error: Can only set one of `children` or `props.dangerouslySetInnerHTML`.
文字列を直接 => Error: `props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. Please visit https://react.dev/link/dangerously-set-inner-html for more information.
キー名が_html => Error: `props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. Please visit https://react.dev/link/dangerously-set-inner-html for more information.
__htmlがundefined => <div></div>
imgに指定 => Error: img is a self-closing tag and must neither have `children` nor use `dangerouslySetInnerHTML`.
書き間違い 結果 直し方
子要素と同時に指定 エラーで止まる 子要素を消すか、別の要素に分ける
文字列をそのまま渡す エラーで止まる { __html: html } の形にする
キー名が _html(アンダースコア1つ) エラーで止まる __html(2つ)にする
__html が undefined エラーにならず、空の要素になる 値が入ってから表示するか、空文字を既定値にする
img に指定 エラーで止まる div や span など中身を持てる要素に指定する

4番目はエラーが出ないまま何も表示されないので、「表示されない」ときはまず __html に渡している値を console.log で確かめると早く見つかります。

サニタイズなしで渡すと何が起きるかを Chromium で確かめる

dangerouslySetInnerHTML は、ブラウザの innerHTML と同じ働きをします。つまり、文字列に含まれる属性やタグは、そのままページの一部になります。React の公式ドキュメントも、信頼できてサニタイズ済みのデータにだけ使うよう案内しています。

実際にどうなるかを、10種類の文字列で確かめました。次のコンポーネントを Chromium で開き、alert が呼ばれた回数を数えています(alert は「入力された文字列の中のコードが動いた」ことの目印です)。

// client.tsx
import { createRoot } from "react-dom/client";
import DOMPurify from "dompurify";

const params = new URLSearchParams(location.search);
const samples: string[] = (window as any).SAMPLES;
const dirty = samples[Number(params.get("i"))];
const html = params.get("clean") === "1" ? DOMPurify.sanitize(dirty) : dirty;

createRoot(document.getElementById("root")!).render(
  <div id="box" dangerouslySetInnerHTML={{ __html: html }} />
);

そのまま渡した場合の結果です。

番号 渡した文字列 表示した直後 リンクやボタンを押した後
1 <p>こんにちは</p><script>alert(1)</script> 動かない ―
2 <img src="x" onerror="alert(1)"> alert が出た ―
3 <a href="javascript:alert(1)">開く</a> 動かない alert が出た
4 <svg onload="alert(1)"><circle r="10"></circle></svg> alert が出た ―
5 <iframe src="javascript:alert(1)"></iframe> alert が出た ―
6 <button onclick="alert(1)">送信</button> 動かない alert が出た
7 <p style="color:red" class="note">赤い文字</p> 動かない ―
8 <form action="https://example.com/"><input name="q"></form> 動かない ―
9 <a href="data:text/html,<script>alert(1)</script>">開く</a> 動かない alert は出なかった
10 <b>太字<i>斜体(閉じ忘れ) 動かない ―

10種類のうち、5種類(2・3・4・5・6)でコードが動きました。同じ10種類を DOMPurify.sanitize に通してから渡した場合は、表示した直後も、押した後も、alert は1回も出ませんでした。

script タグは「動かない」とは限らない

番号1の script タグは、クライアント側で表示したときは動きませんでした。innerHTML で入れた script は実行されない、というブラウザの決まりによるものです。

ただし、サーバー側でHTMLを作る場合は結果が変わります。renderToString で作ったHTMLを Chromium で開くと、script が実行されました。

// ssr-browser.tsx
import { renderToString } from "react-dom/server";
import { chromium } from "playwright";

const dirty = `<p>こんにちは</p><script>alert("script")</script>`;
const html = renderToString(<div dangerouslySetInnerHTML={{ __html: dirty }} />);
console.log("サーバーで作ったHTML:", html);

const browser = await chromium.launch();
const page = await browser.newPage();
const messages: string[] = [];
page.on("dialog", async (d) => { messages.push(d.message()); await d.dismiss(); });
await page.setContent(`<!doctype html><meta charset="utf-8"><div id="root">${html}</div>`);
await page.waitForTimeout(300);
console.log("ブラウザで開いたときの alert:", messages);
await browser.close();
サーバーで作ったHTML: <div><p>こんにちは</p><script>alert("script")</script></div>
ブラウザで開いたときの alert: [ 'script' ]

「script は innerHTML では動かないから大丈夫」と考えるのではなく、表示のしかたにかかわらずサニタイズするのが確実です。

DOMPurify でサニタイズした前後の出力一覧

DOMPurify は、HTML文字列から動いてしまう部分を取り除くライブラリです。ブラウザではそのまま使えます。Node.js で動かすときは、jsdom(ブラウザのDOMを Node.js 上で再現するライブラリ)の window を渡します。この書き方は DOMPurify の README に載っているものです。

npm install dompurify jsdom
// purify.mjs
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);

const samples = [
  ["1 scriptタグ", `<p>こんにちは</p><script>alert(1)</script>`],
  ["2 img onerror", `<img src="x" onerror="alert(1)">`],
  ["3 javascript:リンク", `<a href="javascript:alert(1)">開く</a>`],
  ["4 svg onload", `<svg onload="alert(1)"><circle r="10"></circle></svg>`],
  ["5 iframe", `<iframe src="javascript:alert(1)"></iframe>`],
  ["6 onclick属性", `<button onclick="alert(1)">送信</button>`],
  ["7 style属性", `<p style="color:red" class="note">赤い文字</p>`],
  ["8 form と input", `<form action="https://example.com/"><input name="q"></form>`],
  ["9 data: リンク", `<a href="data:text/html,<script>alert(1)</script>">開く</a>`],
  ["10 閉じ忘れのタグ", `<b>太字<i>斜体`],
];

for (const [label, dirty] of samples) {
  console.log(`[${label}]`);
  console.log("  前:", dirty);
  console.log("  後:", DOMPurify.sanitize(dirty));
}
console.log("version:", DOMPurify.version, "isSupported:", DOMPurify.isSupported);
DOMPurify.sanitize(`<img src="x" onerror="alert(1)"><script>alert(1)</script>`);
console.log("removed:", DOMPurify.removed.map((r) => r.element ? "要素 " + r.element.nodeName : "属性 " + r.attribute.name));

node purify.mjs の出力を表にまとめます。

番号 種類 サニタイズ前 サニタイズ後 変化
1 scriptタグ <p>こんにちは</p><script>alert(1)</script> <p>こんにちは</p> script が要素ごと消える
2 img onerror <img src="x" onerror="alert(1)"> <img src="x"> onerror 属性だけ消える
3 javascript:リンク <a href="javascript:alert(1)">開く</a> <a>開く</a> href が消え、文字は残る
4 svg onload <svg onload="alert(1)"><circle r="10"></circle></svg> <svg><circle r="10"></circle></svg> onload 属性だけ消える
5 iframe <iframe src="javascript:alert(1)"></iframe> (空文字) 要素ごと消える
6 onclick属性 <button onclick="alert(1)">送信</button> <button>送信</button> onclick 属性だけ消える
7 style属性 <p style="color:red" class="note">赤い文字</p> <p style="color:red" class="note">赤い文字</p> 変化なし
8 form と input <form action="https://example.com/"><input name="q"></form> <form action="https://example.com/"><input name="q"></form> 変化なし
9 data: リンク <a href="data:text/html,<script>alert(1)</script>">開く</a> <a>開く</a> href が消える
10 閉じ忘れ <b>太字<i>斜体 <b>太字<i>斜体</i></b> 閉じタグが補われる

出力の最後の2行は次のとおりです。

version: 3.4.16 isSupported: true
removed: [ '属性 onerror', '要素 SCRIPT' ]

この表から読み取れることを整理します。

  • on で始まるイベント属性(onerror・onload・onclick)は、属性だけが取り除かれます。 要素そのものは残ります。
  • script と iframe は要素ごと消えます。
  • javascript: や data: で始まるリンク先は取り除かれ、リンクの文字だけが残ります。
  • style 属性、class 属性、form、input は既定では残ります(番号7・8)。 見た目を変えられたくない場合や、入力欄を置かれたくない場合は、次の章の設定で絞ります。
  • 閉じ忘れのタグは補われるので、ページの後ろ側のレイアウトが崩れることも防げます。

DOMPurify.removed には、直前のサニタイズで取り除かれたものが入ります。ログに残して、どんな入力が来ているかを把握する用途に使えます。README では、この値を安全かどうかの判定には使わないよう案内されています。

ALLOWED_TAGS と ALLOWED_ATTR で使えるタグを絞る

既定の設定は、HTML・SVG・MathML の幅広いタグを通します。コメント欄や説明文など、使ってよいタグが決まっている場合は、許可リストで絞ると意図がはっきりします。

// config.mjs
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

const DOMPurify = createDOMPurify(new JSDOM("").window);

const dirty =
  `<h2 id="top">お知らせ</h2>` +
  `<p style="color:red" class="note">本文に<b>太字</b>と<a href="https://example.com/" title="外部">リンク</a>。</p>` +
  `<img src="https://example.com/a.png" alt="図"><table><tr><td>表</td></tr></table>`;

console.log("既定:\n ", DOMPurify.sanitize(dirty));
console.log("ALLOWED_TAGS + ALLOWED_ATTR:\n ", DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a", "br", "ul", "ol", "li"],
  ALLOWED_ATTR: ["href", "title"],
}));
console.log("ALLOWED_TAGS: [](文字だけ残す):\n ", DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [] }));
console.log("KEEP_CONTENT: false:\n ", DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ["p"], KEEP_CONTENT: false }));
console.log("FORBID_TAGS + FORBID_ATTR:\n ", DOMPurify.sanitize(dirty, { FORBID_TAGS: ["img"], FORBID_ATTR: ["style", "class", "id"] }));
console.log("USE_PROFILES html:\n ", DOMPurify.sanitize(`<p>図</p><svg><circle r="10"></circle></svg><math><mi>x</mi></math>`, { USE_PROFILES: { html: true } }));
console.log("ADD_ATTR target:\n ", DOMPurify.sanitize(`<a href="https://example.com/" target="_blank">開く</a>`, { ADD_ATTR: ["target"] }));
console.log("既定の target:\n ", DOMPurify.sanitize(`<a href="https://example.com/" target="_blank">開く</a>`));
既定:
  <h2 id="top">お知らせ</h2><p style="color:red" class="note">本文に<b>太字</b>と<a href="https://example.com/" title="外部">リンク</a>。</p><img src="https://example.com/a.png" alt="図"><table><tbody><tr><td>表</td></tr></tbody></table>
ALLOWED_TAGS + ALLOWED_ATTR:
  お知らせ<p>本文に<b>太字</b>と<a href="https://example.com/" title="外部">リンク</a>。</p>表
ALLOWED_TAGS: [](文字だけ残す):
  お知らせ本文に太字とリンク。表
KEEP_CONTENT: false:
  <p style="color:red" class="note"></p>
FORBID_TAGS + FORBID_ATTR:
  <h2>お知らせ</h2><p>本文に<b>太字</b>と<a href="https://example.com/" title="外部">リンク</a>。</p><table><tbody><tr><td>表</td></tr></tbody></table>
USE_PROFILES html:
  <p>図</p>
ADD_ATTR target:
  <a href="https://example.com/" target="_blank">開く</a>
既定の target:
  <a href="https://example.com/">開く</a>
設定 働き 今回の出力でわかること
ALLOWED_TAGS 通すタグを列挙する リストにない h2・img・table はタグだけ外れ、中の文字は残る
ALLOWED_ATTR 通す属性を列挙する style・class が消え、href・title だけ残る
ALLOWED_TAGS: [] タグをすべて外す 文字だけの文章になる(一覧の抜粋表示などに使える)
KEEP_CONTENT: false 外したタグの中身も消す 許可していないタグの中の文字ごと消える
FORBID_TAGS / FORBID_ATTR 既定から特定のタグ・属性だけ除く img と style・class・id が消え、ほかは残る
USE_PROFILES: { html: true } HTMLのタグだけを通す svg と math が消える
ADD_ATTR / ADD_TAGS 既定に追加で通す target 属性が残るようになる

押さえておきたい点は2つです。

  • 許可していないタグは「タグだけ」が外れ、中の文字は残ります(h2 の「お知らせ」が文字として残っています)。文字ごと消したいときは KEEP_CONTENT を false にします。
  • target 属性は既定では取り除かれます。 別タブで開くリンクを使いたいときは、次の章のフックで付けます。

用途別の設定の目安

用途 設定の目安
一覧に出す抜粋(文字だけでよい) ALLOWED_TAGS: []
コメントや短い説明文 ALLOWED_TAGS に p・br・b・strong・i・em・a・ul・ol・li、ALLOWED_ATTR に href・title
エディタで書いた記事本文 既定をもとに、FORBID_TAGS で form・input・style など不要なものを除く
図(SVG)を含めない文章 USE_PROFILES: { html: true }

target=_blank に rel を足すフックの設定例

DOMPurify には、サニタイズの途中に処理をはさむフックがあります。afterSanitizeAttributes は、要素の属性を調べ終わった後に呼ばれます。ここで a 要素に target と rel を付けます。

rel="noopener noreferrer" は、別タブで開いた先のページから、元のページを操作できないようにするための指定です。

// hook.mjs
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

const DOMPurify = createDOMPurify(new JSDOM("").window);

// a 要素を見つけたら、別タブで開く設定と rel を付ける
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "A" && node.hasAttribute("href")) {
    node.setAttribute("target", "_blank");
    node.setAttribute("rel", "noopener noreferrer");
  }
});

const dirty =
  `<a href="https://example.com/">外部サイト</a> ` +
  `<a href="javascript:alert(1)">動かないリンク</a> ` +
  `<a href="https://example.com/" target="_blank" rel="opener">relを書き換える</a>`;

console.log(DOMPurify.sanitize(dirty));
DOMPurify.removeAllHooks();
console.log(DOMPurify.sanitize(dirty));
<a href="https://example.com/" target="_blank" rel="noopener noreferrer">外部サイト</a> <a>動かないリンク</a> <a href="https://example.com/" rel="noopener noreferrer" target="_blank">relを書き換える</a>
<a href="https://example.com/">外部サイト</a> <a>動かないリンク</a> <a href="https://example.com/" rel="opener">relを書き換える</a>

1行目がフックあり、2行目がフックを外した後です。

  • href が残っているリンクにだけ target と rel が付きました。javascript: のリンクは href が取り除かれているので、フックの条件(href を持つ)に当てはまらず、何も付きません。
  • 入力側で rel="opener" と書かれていても、フックで noopener noreferrer に書き換わります。
  • フックは登録したインスタンス全体に効き続けます。アプリの起動時に1回だけ登録する形にすると、二重登録を避けられます。

README に載っているフックは、beforeSanitizeElements・uponSanitizeElement・afterSanitizeElements、beforeSanitizeAttributes・uponSanitizeAttribute・afterSanitizeAttributes、beforeSanitizeShadowDOM・uponSanitizeShadowNode・afterSanitizeShadowDOM の9種類です。

サーバー側で DOMPurify が動かないときは isomorphic-dompurify

DOMPurify はブラウザのDOMを使って動きます。Node.js にはDOMがないので、サーバー側でレンダリングするコード(renderToString や、サーバー側でも実行されるコンポーネント)で dompurify をそのまま import すると、サニタイズの関数が使えません。

// server.mjs
import DOMPurify from "dompurify";

console.log("typeof window:", typeof window);
console.log("isSupported:", DOMPurify.isSupported);
try {
  console.log(DOMPurify.sanitize("<b>x</b><script>alert(1)</script>"));
} catch (e) {
  console.log(e.name + ": " + e.message);
}
typeof window: undefined
isSupported: false
TypeError: DOMPurify.sanitize is not a function

また、window.DOMPurify のように window を直接書いたコードをサーバー側で動かすと、次のエラーになります。

// server2.mjs
const clean = window.DOMPurify?.sanitize("<b>x</b>");
console.log(clean);
ReferenceError: window is not defined
サーバー側で出るエラー 原因
TypeError: DOMPurify.sanitize is not a function window がない場所で dompurify を import した(isSupported が false)
ReferenceError: window is not defined コードの中で window を直接参照した

isomorphic-dompurify に置き換える

isomorphic-dompurify は、ブラウザでは DOMPurify を、Node.js では jsdom と組み合わせた DOMPurify を使うようにまとめたパッケージです。DOMPurify の README でも、サーバー側とブラウザの両方で使うときの選択肢として紹介されています。import の行を変えるだけで、同じコードがサーバー側でも動きます。

npm install isomorphic-dompurify
// iso.mjs
import DOMPurify from "isomorphic-dompurify";

console.log("typeof window:", typeof window);
console.log(DOMPurify.sanitize(`<b>x</b><script>alert(1)</script><img src="x" onerror="alert(1)">`));
typeof window: undefined
<b>x</b><img src="x">

window がない Node.js 上でも、script と onerror が取り除かれました。

動かす場所 使うパッケージ
ブラウザだけ dompurify
Node.js だけ(APIサーバーで保存前に処理するなど) dompurify と jsdom(README の書き方)
ブラウザとサーバーの両方(サーバー側レンダリング) isomorphic-dompurify

README は、jsdom を最新の版で使うことを強くすすめています。サーバー側で使う場合は、jsdom の更新も定期的に行うと安心です。

コピーして使える SafeHtml コンポーネント

ここまでの内容を、1つのコンポーネントにまとめます。サニタイズと { __html } の作成を同じ場所に置くことで、サニタイズを通さない値が渡る余地をなくせます。

// SafeHtml.tsx
import { useMemo } from "react";
import DOMPurify from "isomorphic-dompurify";

const CONFIG = {
  ALLOWED_TAGS: ["p", "br", "b", "strong", "i", "em", "a", "ul", "ol", "li", "h2", "h3"],
  ALLOWED_ATTR: ["href", "title"],
};

type Props = { html: string; className?: string };

export function SafeHtml({ html, className }: Props) {
  const markup = useMemo(() => ({ __html: DOMPurify.sanitize(html, CONFIG) }), [html]);
  return <div className={className} dangerouslySetInnerHTML={markup} />;
}
// safe.tsx
import { renderToString } from "react-dom/server";
import { SafeHtml } from "./SafeHtml";

const fromEditor =
  `<h2>入荷のお知らせ</h2><p>新しい<b>備品</b>が届きました。<a href="https://example.com/items">一覧を見る</a></p>` +
  `<img src="x" onerror="alert(1)"><script>alert(1)</script>`;

console.log(renderToString(<SafeHtml html={fromEditor} className="article" />));
<div class="article"><h2>入荷のお知らせ</h2><p>新しい<b>備品</b>が届きました。<a href="https://example.com/items">一覧を見る</a></p></div>

見出し・段落・太字・リンクは残り、img と script は取り除かれました。ポイントは次の3つです。

  • useMemo で、html が変わったときだけサニタイズし直します。 長い文章でも、再レンダーのたびに処理が走ることを避けられます。
  • 設定(CONFIG)はコンポーネントの外に置きます。 毎回同じオブジェクトを使えます。
  • 画面のあちこちで dangerouslySetInnerHTML を直接書く代わりに、このコンポーネントだけを使う決まりにすると、確認する場所が1か所で済みます。

DOMPurify の README には、サニタイズした後のHTMLを書き換えると、サニタイズの効果が失われることがある、という注意があります。サニタイズは表示の直前に行い、その結果には手を加えないのが基本です。

html-react-parser・react-markdown との使い分け

HTMLやMarkdownを表示する方法は、dangerouslySetInnerHTML のほかにもあります。それぞれ実行して、同じ種類の文字列がどう扱われるかを確かめました。

html-react-parser:HTMLを React の要素に変換する

html-react-parser は、HTML文字列を React の要素に変換します。リンクを自作のコンポーネントに差し替える、といった加工ができるのが特長です。

// parser.tsx
import { renderToString } from "react-dom/server";
import parse, { domToReact, Element, type DOMNode } from "html-react-parser";
import DOMPurify from "isomorphic-dompurify";

const html =
  `<p>本文に<a href="/items">一覧</a>へのリンク。</p>` +
  `<img src="x" onerror="alert(1)"><script>alert(1)</script><a href="javascript:alert(1)">開く</a>`;

// 1. そのまま parse(サニタイズなし)
console.log("1:", renderToString(<div>{parse(html)}</div>));

// 2. DOMPurify を通してから parse
console.log("2:", renderToString(<div>{parse(DOMPurify.sanitize(html))}</div>));

// 3. replace で a 要素を自作コンポーネントに差し替える
function AppLink(props: { to: string; children: React.ReactNode }) {
  return <a href={props.to} data-app-link="true">{props.children}</a>;
}
const replaced = parse(DOMPurify.sanitize(html), {
  replace(node: DOMNode) {
    if (node instanceof Element && node.name === "a" && node.attribs.href?.startsWith("/")) {
      return <AppLink to={node.attribs.href}>{domToReact(node.children as DOMNode[])}</AppLink>;
    }
  },
});
console.log("3:", renderToString(<div>{replaced}</div>));

出力です(先頭に付く画像の先読み用の link 要素は省いています)。

1: <div><p>本文に<a href="/items">一覧</a>へのリンク。</p><img src="x"/><script>alert(1)</script><a href="javascript:throw new Error(&#x27;React has blocked a javascript: URL as a security precaution.&#x27;)">開く</a></div>
2: <div><p>本文に<a href="/items">一覧</a>へのリンク。</p><img src="x"/><a>開く</a></div>
3: <div><p>本文に<a href="/items" data-app-link="true">一覧</a>へのリンク。</p><img src="x"/><a>開く</a></div>

1の実行時には、React から次の警告も出ました。

Invalid event handler property `onerror`. React events use the camelCase naming convention, for example `onClick`.
  • React の要素に変換されるので、onerror のような文字列のイベント属性は React が受け付けず、javascript: のリンクも React が置き換えます。
  • 一方で、1の出力には script 要素がそのまま残っています。html-react-parser は変換のためのライブラリで、サニタイズはしません。DOMPurify を通してから parse する(2・3)形で使います。
  • 3では、/ で始まるリンクだけを自作の AppLink に差し替えられました。ルーティング用のリンクコンポーネントに置き換えたいときに役立ちます。

react-markdown:Markdown を表示する

入力が Markdown の場合は、react-markdown が向いています。HTMLに変換してから dangerouslySetInnerHTML に渡すのではなく、直接 React の要素として描画します。

// md.tsx
import { renderToString } from "react-dom/server";
import Markdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";

const md = [
  "## 入荷のお知らせ",
  "",
  "新しい**備品**が届きました。[一覧を見る](https://example.com/items)",
  "",
  "[動かないリンク](javascript:alert(1))",
  "",
  '<img src="x" onerror="alert(1)"><b>HTMLの太字</b>',
].join("\n");

console.log("1 既定:\n", renderToString(<Markdown>{md}</Markdown>));
console.log("2 rehype-raw のみ:\n", renderToString(<Markdown rehypePlugins={[rehypeRaw]}>{md}</Markdown>));
console.log("3 rehype-raw + rehype-sanitize:\n", renderToString(<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{md}</Markdown>));

出力です(2と3の先頭に付く画像の先読み用の link 要素は省いています)。

1 既定:
 <h2>入荷のお知らせ</h2>
<p>新しい<strong>備品</strong>が届きました。<a href="https://example.com/items">一覧を見る</a></p>
<p><a href="">動かないリンク</a></p>
<p>&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;<!-- -->&lt;b&gt;<!-- -->HTMLの太字<!-- -->&lt;/b&gt;</p>
2 rehype-raw のみ:
 <h2>入荷のお知らせ</h2>
<p>新しい<strong>備品</strong>が届きました。<a href="https://example.com/items">一覧を見る</a></p>
<p><a href="">動かないリンク</a></p>
<p><img src="x"/><b>HTMLの太字</b></p>
3 rehype-raw + rehype-sanitize:
 <h2>入荷のお知らせ</h2>
<p>新しい<strong>備品</strong>が届きました。<a href="https://example.com/items">一覧を見る</a></p>
<p><a>動かないリンク</a></p>
<p><img src="x"/><b>HTMLの太字</b></p>
  • 既定では、Markdown の中に書かれた生のHTMLは文字として表示されます(1の最後の行)。javascript: のリンク先は空になりました。
  • 生のHTMLも働かせたいときは rehype-raw(7.0.0)を足します。その場合は rehype-sanitize(6.0.0)も一緒に指定します(3)。

使い分けの判断表

表示したいもの 向いている方法 サニタイズ
利用者が入力した文字(タグを働かせる必要がない) {text} をそのまま書く 不要(React がエスケープする)
エディタやCMSから来るHTMLを、そのままの見た目で表示 DOMPurify + dangerouslySetInnerHTML DOMPurify で行う
HTMLの一部を自作コンポーネントに差し替えたい DOMPurify + html-react-parser DOMPurify で行う(parser だけでは script が残る)
Markdown を表示したい react-markdown 既定のまま使う。生のHTMLを通すなら rehype-sanitize を足す
検索語の強調など、自分のコードで組み立てられるもの 文字列を分割して JSX で組み立てる 不要

迷ったときは、「HTMLとして表示する必要が本当にあるか」を最初に考えます。文字として出せば足りる場面では {} がいちばん簡単で、確認することも少なく済みます。

よくある質問

Q. 自分のサーバーから取得したHTMLなら、サニタイズしなくてもよいですか? A. 取得元が自分のサーバーでも、その中身が利用者の入力から作られているなら、サニタイズしてから表示します。React の公式ドキュメントも、完全に信頼できる出どころのもの以外は、信頼できてサニタイズ済みのデータにだけ使うよう案内しています。コードに直接書いた固定のHTMLのように、中身を自分で決めている場合はそのまま渡せます。

Q. サニタイズは保存するときと表示するとき、どちらで行いますか? A. 表示の直前に行うのが基本です。DOMPurify の README には、サニタイズ後に書き換えると効果が失われることがある、という注意があります。保存時にも行う場合は、表示時にも重ねて行うと、設定を後から厳しくしたときにも過去のデータへ反映できます。

Q. style 属性や class 属性は取り除かれますか? A. 今回の実行では、既定の設定のままだと style と class は残りました。見た目を変えられたくない場合は、ALLOWED_ATTR で通す属性を列挙するか、FORBID_ATTR: ["style", "class"] で除きます。

Q. 「DOMPurify.sanitize is not a function」と出ます。 A. window がない場所(Node.js やサーバー側レンダリング)で dompurify を使っています。import を isomorphic-dompurify に変えると、同じ書き方のままサーバー側でも動きました。

まとめ

  • dangerouslySetInnerHTML には { __html: 文字列 } の形のオブジェクトを渡します。 子要素との併用や、文字列を直接渡す書き方はエラーになります。
  • {} に入れた文字列は自動でエスケープされます。 HTMLとして表示する必要がないときは、この書き方がいちばん簡単です。
  • サニタイズなしで渡すと、10種類のうち5種類でコードが動きました。 DOMPurify を通した後は1つも動きませんでした。
  • DOMPurify は、イベント属性・script・iframe・javascript: のリンク先を取り除きます。 style・class・form は既定で残るので、ALLOWED_TAGS と ALLOWED_ATTR で絞ります。
  • 別タブで開くリンクには、afterSanitizeAttributes フックで rel="noopener noreferrer" を付けます。
  • サーバー側では isomorphic-dompurify を使います。 dompurify をそのまま使うと「DOMPurify.sanitize is not a function」になります。
  • html-react-parser は変換、react-markdown は Markdown の表示に使います。 HTMLを通す場合は、どちらもサニタイズと組み合わせます。

参考・出典

  • React 公式ドキュメント「Common components」の dangerouslySetInnerHTML の項 https://react.dev/reference/react-dom/components/common
  • DOMPurify README https://github.com/cure53/DOMPurify

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「dangerouslySetInnerHTMLの使い方とサニタイズ」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?