react-markdownでMarkdownを安全に表示する方法
react-markdown は、何も設定しなければ、Markdown の中に書かれた HTML を文字のまま表示し、javascript: で始まるリンクの URL も消します。利用者が書いた Markdown をそのまま渡しても、仕込まれたコードは動きません。危なくなるのは、HTML も表示したくて rehype-raw を足したときや、URL の扱いを変えたときです。
筆者は検証環境で、コメント欄を想定したコンポーネントをいくつかの書き方で作り、仕込んだコードが動くかどうかを Chromium で確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、react-markdown 10.1.0、rehype-raw 7.0.0、rehype-sanitize 6.0.0、typescript 7.0.2 です。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright でリンクを1つずつクリックしました。ページは検証用に作ったもので、外部のサーバーにはつないでいません。本番用のビルドでの動きは試していません。
試したMarkdown
利用者が投稿したものとして、次の Markdown を用意しました。見出しと太字のほかに、javascript: のリンク、onerror 付きの <img>、javascript: の URL を入れた <a>、srcdoc の中に <script> を書いた <iframe>、<script> タグを入れています。__hit は、コードが動いたことを記録するために検証用のページに用意した関数です。
export const markdown = [
'## 感想',
'',
'**とても**参考になりました。',
'',
'[詳しくはこちら](javascript:__hit(\'Markdownのリンク\'))',
'',
'<img src="x" onerror="__hit(\'imgのonerror\')">',
'',
'<a href="javascript:__hit(\'HTMLのリンク\')">HTMLのリンク</a>',
'',
'<iframe srcdoc="<script>parent.__hit(\'iframeのsrcdoc\')</script>"></iframe>',
'',
'<script>__hit(\'scriptタグ\')</script>',
].join('\n');
それぞれのコンポーネントは、この文字列を body として受け取り、react-markdown の Markdown コンポーネントに渡します。描画したあと、コンポーネントの中のリンクを1つずつクリックしました。href が空のリンクは、クリックするとページが読み込み直されて記録が消えてしまうため、検証用のページでその場合だけ移動を止めています。
何も設定しなければ、HTMLは文字のまま出る
いちばん素直な書き方は、Markdown に文字列を渡すだけです。
import Markdown from 'react-markdown';
export function PlainComment({ body }: { body: string }) {
return <Markdown>{body}</Markdown>;
}
このコンポーネントが出した HTML は次のとおりです(renderToStaticMarkup で取り出しました)。見出しや太字は要素になり、HTML のタグは < や > に置き換わって、文字として出ています。
<h2>感想</h2>
<p><strong>とても</strong>参考になりました。</p>
<p><a href="">詳しくはこちら</a></p>
<img src="x" onerror="__hit('imgのonerror')">
<p><a href="javascript:__hit('HTMLのリンク')">HTMLのリンク</a></p>
<iframe srcdoc="<script>parent.__hit('iframeのsrcdoc')</script>"></iframe>
<script>__hit('scriptタグ')</script>
react-markdown の README にある、Markdown の中の HTML についての説明にも、HTML は通常エスケープする(skipHtml を指定すれば無視する)と書かれています。
Markdown のリンク [詳しくはこちら](javascript:...) は、href が空になりました。react-markdown は、要素の URL を urlTransform という関数に通してから描画します。指定しなければ defaultUrlTransform が使われます。この関数は、URL の先頭のプロトコルが http、https、irc、ircs、mailto、xmpp のどれかか、プロトコルのない相対 URL なら、そのまま返します。それ以外は空の文字列にします。
ブラウザで描画してリンクをクリックしても、仕込んだコードはどれも動きませんでした。
# PlainComment
クリックしたリンク: 1 個(ページの読み込み: 1 回)
実行記録: []
README のセキュリティの節にも、react-markdown は何も変えなければ安全に使えると書かれています。同じ節は、urlTransform を安全でないものに差し替えたり、安全でないプラグインや components を使ったりすると、その限りではないとしています。
rehype-rawを足すと、HTMLとして読まれる
Markdown の中の HTML も表示したいときは、rehype-raw というプラグインを足します。
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
export function RawComment({ body }: { body: string }) {
return <Markdown rehypePlugins={[rehypeRaw]}>{body}</Markdown>;
}
ブラウザで描画すると、<iframe> の srcdoc に書いた <script> が動きました。<iframe> の中のコードから、外側のページの関数 __hit を呼べています。
# RawComment
クリックしたリンク: 2 個(ページの読み込み: 1 回)
実行記録: ["iframeのsrcdoc"]
ほかの4つは、ブラウザで描画したときは動きませんでした。理由は次のとおりです。
- Markdown のリンクと HTML の
<a>は、どちらもhrefがurlTransformを通って空になりました。 <img>のonerrorの値は、文字列のまま React に渡りました。React は描画のときに「関数ではない」というエラーを出しました。画像の読み込みに失敗したあとにも同じ内容の例外が出て、中のコードは動きませんでした。<script>は要素として入りましたが、React が「クライアントで描画したときは、コンポーネントの中のスクリプトは実行されない」という警告を出し、中のコードは動きませんでした。
ただし、<script> が動かないのは、ブラウザで描画したときだけでした。同じコンポーネントをサーバー側の renderToStaticMarkup で HTML にすると、<script> と <iframe> がそのまま出力に入りました。その HTML をブラウザで開くと、両方のコードが動きました。
<link rel="preload" as="image" href="x"/><h2>感想</h2>
<p><strong>とても</strong>参考になりました。</p>
<p><a href="">詳しくはこちら</a></p>
<img src="x"/>
<p><a href="">HTMLのリンク</a></p>
<iframe srcDoc="<script>parent.__hit('iframeのsrcdoc')</script>"></iframe>
<script>__hit('scriptタグ')</script>
# RawComment(サーバーで HTML にした)
実行記録: ["scriptタグ","iframeのsrcdoc"]
README の HTML についての説明でも、rehype-raw は Markdown の中身を信頼できる場合に使うものとして紹介されています。
ほかの利用者が書いた Markdown を、rehype-raw だけを足した Markdown に渡さないでください。<iframe> の srcdoc のように、React も react-markdown も止めない書き方で、見た人のブラウザでコードが動きます。
HTMLを表示するなら、rehype-sanitizeを最後に置く
HTML も表示する必要があるときは、rehype-sanitize で危ない部分を取り除きます。rehypePlugins の配列で、rehype-raw の後ろに置きます。
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
export function SanitizedComment({ body }: { body: string }) {
return <Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{body}</Markdown>;
}
出力は次のようになりました。<img> は src だけが残り、2つのリンクは href が消え、<iframe> と <script> は要素ごと消えています。ブラウザで描画した場合も、サーバーで HTML にしてから開いた場合も、コードは動きませんでした。
<link rel="preload" as="image" href="x"/><h2>感想</h2>
<p><strong>とても</strong>参考になりました。</p>
<p><a>詳しくはこちら</a></p>
<img src="x"/>
<p><a>HTMLのリンク</a></p>
次の画像は、3つの書き方を縦に並べ、リンクをクリックしたあとの画面です。下の実行記録のピンクの行は、rehype-raw だけを足したコンポーネントの <iframe> が出したものです。

順番が大事なのは、rehype-sanitize の README のセキュリティの節に、rehype-sanitize より後ろで動くものは安全でなくなりうる、とあるためです。同じ節には、後ろで動くものでも信頼できるなら問題ないとも書かれています。HTML を読ませるプラグインや、信頼できると分かっていないプラグインは、rehype-sanitize より前に置きます。
rehype-sanitize が何を残すかは、中で使っている hast-util-sanitize の既定の設定で決まります。検証で使った版(hast-util-sanitize 5.0.2)の設定では、href に使えるプロトコルは http、https、irc、ircs、mailto、xmpp だけでした。id と name の値には user-content- を前に付けるようになっていました。残す要素や属性を変えたいときは、rehype-sanitize の README にある、シンタックスハイライトの例のように、既定の設定をもとに自分の設定を作って渡します。
HTMLが要らないなら、そもそも読ませない
HTML を表示する必要がなければ、rehype-raw は入れません。そのうえで、表示する内容を2つの設定で絞れます。
skipHtml を付けると、HTML の部分は文字としても出さずに捨てます。同じ Markdown では、<img>、<iframe>、<script> の行が消え、<a> は中の文字「HTMLのリンク」だけが段落に残りました。
export function SkipHtmlComment({ body }: { body: string }) {
return <Markdown skipHtml>{body}</Markdown>;
}
allowedElements には、描画してよい要素の名前を並べます。unwrapDisallowed を付けると、許していない要素は、要素だけを外して中身を残します。次のコンポーネントでは h2 を許していないので、見出しが「感想」という文字だけになりました。
const allowed = ['p', 'strong', 'em', 'a', 'code', 'ul', 'ol', 'li'];
export function LimitedComment({ body }: { body: string }) {
return (
<Markdown allowedElements={allowed} unwrapDisallowed>
{body}
</Markdown>
);
}
allowedElements は、描画する要素を名前で絞る設定です。ここでは rehype-raw を入れていないので、HTML のタグは要素にならず、文字のまま残りました。どちらのコンポーネントでも、仕込んだコードは動きませんでした。
urlTransformやcomponentsを変えるときの注意
urlTransform を、URL をそのまま返す関数に差し替えたコンポーネントも試しました。
export function NoUrlCheckComment({ body }: { body: string }) {
return <Markdown urlTransform={(url) => url}>{body}</Markdown>;
}
このコンポーネントでは、react-markdown は javascript: の URL を消さなくなります。それでもコードが動かなかったのは、React 19.3.1 が href を別の URL に置き換えたためです。クリックすると「React has blocked a javascript: URL as a security precaution.」というエラーが出ました。
# NoUrlCheckComment
クリックしたリンク: 1 個(ページの読み込み: 1 回)
実行記録: []
pageerror: React has blocked a javascript: URL as a security precaution.
これは React 側の守りで、react-markdown の設定とは関係ありません。urlTransform を差し替えるときは、defaultUrlTransform を呼んだうえで手を加えるようにします。defaultUrlTransform は react-markdown から import して使えます。
components で要素を自分のコンポーネントに置き換えるときも、渡ってきた値をどう使うかは自分で決めることになります。次のようにリンクを別のタブで開くようにしたコンポーネントでは、href には、urlTransform を通った後の値(ここでは空の文字列)が渡ってきました。
import Markdown, { type Components } from 'react-markdown';
const components: Components = {
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
};
export function ExternalLinkComment({ body }: { body: string }) {
return <Markdown components={components}>{body}</Markdown>;
}
<p><a href="" target="_blank" rel="noopener noreferrer">詳しくはこちら</a></p>
react-markdown は、何も足さなければ、HTML を文字のまま出し、安全なプロトコル以外の URL を消します。HTML を表示しないなら rehype-raw は入れず、必要に応じて skipHtml や allowedElements で絞ります。HTML を表示するなら、rehype-sanitize を rehypePlugins の最後に置きます。





