- React
dangerouslySetInnerHTMLの使い方とサニタイズ
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><b>太字</b>と<a href="https://example.com">リンク</a></div>
B: <div><b>太字</b>と<a href="https://example.com">リンク</a></div>
| 書き方 | 出力 | 画面での見え方 |
|---|---|---|
A:{html} |
タグの記号が < などに置き換わる |
タグが文字のまま見える |
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('React has blocked a javascript: URL as a security precaution.')">開く</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><img src="x" onerror="alert(1)"><!-- --><b><!-- -->HTMLの太字<!-- --></b></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期連続の黒字決算。