ReactのdangerouslySetInnerHTMLが危ない理由
dangerouslySetInnerHTML は、文字列を React のエスケープを通さず、HTML としてそのまま要素の中に入れます。利用者が入力した文字列を渡すと、その中に仕込まれた JavaScript が、ページのほかのコードと同じ権限で動きます。これがクロスサイトスクリプティング(XSS)と呼ばれる弱点です。
筆者は検証環境で、コメント欄を想定した小さな部品を3つの書き方で作り、仕込んだコードが動くかどうかを Chromium で確かめました。あわせて、DOMPurify でサニタイズ(危ない部分を取り除いて無害にすること)したときに何が消えるかも確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、dompurify 3.4.17、typescript 7.0.2 です。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しました。ページは検証用に作ったもので、外部のサーバーにはつないでいません。
試した3つの書き方
比べたのは、同じ文字列を {body} で表示する部品、dangerouslySetInnerHTML で入れる部品、DOMPurify でサニタイズしてから入れる部品の3つです。
import DOMPurify from 'dompurify';
// 1. 文字列として表示する(React がエスケープする)
export function TextComment({ body }: { body: string }) {
return <div className="comment">{body}</div>;
}
// 2. HTML としてそのまま入れる
function toMarkup(html: string) {
return { __html: html };
}
export function HtmlComment({ body }: { body: string }) {
return <div className="comment" dangerouslySetInnerHTML={toMarkup(body)} />;
}
// 3. DOMPurify でサニタイズしてから入れる
function toSafeMarkup(html: string) {
return { __html: DOMPurify.sanitize(html) };
}
export function SafeHtmlComment({ body }: { body: string }) {
return <div className="comment" dangerouslySetInnerHTML={toSafeMarkup(body)} />;
}
body には、利用者が投稿したつもりの、次の文字列を渡しました。読み込みに失敗する画像を置き、失敗したときに動く onerror に JavaScript を書いたものです。__hit は、コードが動いたことを記録するために検証用のページに用意した関数です。localStorage には、検証のために作った dummy-token-123 という値を入れておきました。
export const imgPayload =
`<img src="x" onerror="__hit('localStorage を読めた: ' + localStorage.getItem('demo-token'))">`;
{body} で表示すると、HTMLは文字のまま出る
{body} で表示した部品では、文字列は文字のまま要素に入り、HTML として読まれませんでした。画面には、タグが文字としてそのまま出ます。コードは動きませんでした。#root の中身を読み出すと、< や > は < や > と表示されます。
# img の onerror / {body} で表示
#root の中身: <div class="comment"><img src="x" onerror="__hit('localStorage を読めた: ' + localStorage.getItem('demo-token'))"></div>
実行記録: []
React で文字列を表示するとき、ふだんは何もしなくても、文字列は HTML ではなく文字として扱われます。dangerouslySetInnerHTML は、この守りを外して HTML として読ませる書き方です。
dangerouslySetInnerHTMLでは、仕込まれたコードが動く
同じ文字列を dangerouslySetInnerHTML で入れると、<img> 要素として作られました。画像の読み込みに失敗した時点で onerror が動き、localStorage の値を読み出せました。
# img の onerror / dangerouslySetInnerHTML
#root の中身: <div class="comment"><img src="x" onerror="__hit('localStorage を読めた: ' + localStorage.getItem('demo-token'))"></div>
実行記録: ["localStorage を読めた: dummy-token-123"]
3つの部品を縦に並べて表示したのが、次の画面です。実行記録のピンクの行は、2つ目の部品に入れた onerror が出したものです。

検証では値を記録しただけですが、MDN の XSS の説明によると、仕込まれたコードはページ自身のコードができることを何でもできます。公式リファレンスの dangerouslySetInnerHTML の説明でも、仕込まれたコードが動くと、利用者の情報を盗んだり、利用者になりすまして操作したりできると書かれています。
ほかの利用者が書いた文字列(コメント、プロフィール、投稿の本文など)を、そのまま dangerouslySetInnerHTML に渡さないでください。見た人のブラウザで、書いた人のコードが動きます。
ブラウザでscriptタグが動かなくても、安全とは言えない
<script> タグを入れた文字列でも試しました。ブラウザ側の React が描画した場合は、タグは要素の中に入りましたが、中のコードは動きませんでした。MDN の innerHTML の説明にあるとおり、innerHTML で入れた <script> は実行されないためです。
export const scriptPayload = `<script>__hit('script タグが実行された')</script>`;
ただし、同じ部品をサーバー側の renderToString で HTML にすると、<script> タグがそのまま出力に入りました。その HTML をブラウザで開くと、コードが動きました。{body} で表示した部品では、サーバー側でもタグが <script> に置き換わります。
import { renderToString } from 'react-dom/server';
console.log(renderToString(<TextComment body={scriptPayload} />));
console.log(renderToString(<HtmlComment body={scriptPayload} />));
# {body} で表示した場合
<div class="comment"><script>__hit('script タグが実行された')</script></div>
# dangerouslySetInnerHTML で表示した場合
<div class="comment"><script>__hit('script タグが実行された')</script></div>
# script タグ / dangerouslySetInnerHTML(サーバーで HTML にした)
#root の中身: <div class="comment"><script>__hit('script タグが実行された')</script></div>
実行記録: ["script タグが実行された"]
リンクの href に javascript: で始まる URL を書いた文字列も試しました。dangerouslySetInnerHTML で入れたリンクは、クリックするとコードが動きました。一方、次のように JSX の href に同じような URL を渡した場合は、React 19.3.1 が別の URL に置き換え、クリックしてもコードは動きませんでした。
export function JsxLink({ href }: { href: string }) {
return <a href={href}>詳しくはこちら</a>;
}
// href に "javascript:__hit('JSX の javascript: のリンクが実行された')" を渡した
# javascript: のリンク / dangerouslySetInnerHTML(クリックした)
#root の中身: <div class="comment"><a href="javascript:__hit('javascript: のリンクが実行された')">詳しくはこちら</a></div>
実行記録: ["javascript: のリンクが実行された"]
# javascript: のリンク / JSX の href に渡した(クリックした)
#root の中身: <a href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')">詳しくはこちら</a>
実行記録: []
ページのエラー: React has blocked a javascript: URL as a security precaution.
React が JSX に対して入れている守りは、__html の中の HTML には届きません。dangerouslySetInnerHTML に渡した文字列の中身は、ブラウザがそのまま HTML として読みます。
DOMPurifyでサニタイズしてから渡す
HTML をそのまま表示する必要があるときは、渡す前に DOMPurify で危ない部分を取り除きます。DOMPurify.sanitize は、HTML の文字列を受け取り、サニタイズした文字列を返します。
検証環境では、3種類の文字列がそれぞれ次のようになり、どれもコードは動きませんでした。
| 渡した文字列 | dangerouslySetInnerHTML にそのまま渡した場合 |
DOMPurify を通した後 |
|---|---|---|
onerror 付きの <img> |
読み込みに失敗した時点で動いた | <img src="x">(onerror が消えた) |
<script> タグ |
ブラウザで描画したときは動かず、サーバーで HTML にしたときは動いた | タグごと消えた(ブラウザで確認) |
javascript: の URL のリンク |
クリックすると動いた | <a>詳しくはこちら</a>(href が消えた) |
DOMPurify の README の注意点には、サニタイズした後の HTML に手を加えると、サニタイズの効果がなくなることがあると書かれています。そのため、サニタイズした文字列には手を加えず、そのまま __html に渡します。検証の部品でも、toSafeMarkup の中でサニタイズした結果をそのまま渡しています。
DOMPurify を動かすには DOM が必要です。DOM のない Node.js でそのまま読み込むと、isSupported は false になりました。sanitize という関数もなく、呼ぶとエラーになりました。README によると、サーバー側で使うときは jsdom を組み合わせます。筆者は jsdom と組み合わせたサニタイズは試していません。
isSupported: false
typeof sanitize: undefined
エラー: DOMPurify.sanitize is not a function
渡す前に確かめたいこと
文字列を表示するだけなら、dangerouslySetInnerHTML は使わずに {body} で表示します。HTML として表示したいときは、その HTML がどこから来たものかを確かめます。
公式リファレンスでは、{ __html } のオブジェクトを、HTML を作る場所のすぐ近くで作ることが勧められています。HTML として扱う値がコードの上ではっきりし、HTML が入っていると分かっている値だけを渡せるようにするためです。検証の部品では、サニタイズして HTML を作る toSafeMarkup がこの書き方に当たります。toMarkup は危ない例として用意したもので、どんな文字列でも HTML として扱う点では、JSX の中に {{ __html: markup }} と書くのと変わりません。
同じリファレンスには、サイトで Trusted Types を強制している場合は、__html にセキュリティポリシーで作った TrustedHTML の値を渡すようにとも書かれています。筆者は Trusted Types の設定は試していません。
dangerouslySetInnerHTML が危ないのは、React がふだん入れているエスケープを外し、文字列をブラウザに HTML として読ませるからです。<script> が動かない場面があっても、onerror や javascript: のリンクのように、コードを動かす書き方はほかにもあります。信頼できない文字列は {body} で表示し、HTML が必要なら DOMPurify でサニタイズしてから渡します。





