react-markdownでMarkdownを安全に表示する方法

​​React​react-markdown,rehype-sanitize,TypeScript,XSS

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 は、コードが動いたことを記録するために検証用のページに用意した関数です。

ts
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 に文字列を渡すだけです。

tsx
import Markdown from 'react-markdown';

export function PlainComment({ body }: { body: string }) {
  return <Markdown>{body}</Markdown>;
}

このコンポーネントが出した HTML は次のとおりです(renderToStaticMarkup で取り出しました)。見出しや太字は要素になり、HTML のタグは &lt; や &gt; に置き換わって、文字として出ています。

text
<h2>感想</h2>
<p><strong>とても</strong>参考になりました。</p>
<p><a href="">詳しくはこちら</a></p>
&lt;img src=&quot;x&quot; onerror=&quot;__hit(&#x27;imgのonerror&#x27;)&quot;&gt;
<p>&lt;a href=&quot;javascript:__hit(&#x27;HTMLのリンク&#x27;)&quot;&gt;HTMLのリンク&lt;/a&gt;</p>
&lt;iframe srcdoc=&quot;&lt;script&gt;parent.__hit(&#x27;iframeのsrcdoc&#x27;)&lt;/script&gt;&quot;&gt;&lt;/iframe&gt;
&lt;script&gt;__hit(&#x27;scriptタグ&#x27;)&lt;/script&gt;

react-markdown の README にある、Markdown の中の HTML についての説明にも、HTML は通常エスケープする(skipHtml を指定すれば無視する)と書かれています。

Markdown のリンク [詳しくはこちら](javascript:...) は、href が空になりました。react-markdown は、要素の URL を urlTransform という関数に通してから描画します。指定しなければ defaultUrlTransform が使われます。この関数は、URL の先頭のプロトコルが http、https、irc、ircs、mailto、xmpp のどれかか、プロトコルのない相対 URL なら、そのまま返します。それ以外は空の文字列にします。

ブラウザで描画してリンクをクリックしても、仕込んだコードはどれも動きませんでした。

text
# PlainComment
  クリックしたリンク: 1 個(ページの読み込み: 1 回)
  実行記録: []

README のセキュリティの節にも、react-markdown は何も変えなければ安全に使えると書かれています。同じ節は、urlTransform を安全でないものに差し替えたり、安全でないプラグインや components を使ったりすると、その限りではないとしています。

rehype-rawを足すと、HTMLとして読まれる

Markdown の中の HTML も表示したいときは、rehype-raw というプラグインを足します。

tsx
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 を呼べています。

text
# RawComment
  クリックしたリンク: 2 個(ページの読み込み: 1 回)
  実行記録: ["iframeのsrcdoc"]

ほかの4つは、ブラウザで描画したときは動きませんでした。理由は次のとおりです。

  • Markdown のリンクと HTML の <a> は、どちらも href が urlTransform を通って空になりました。
  • <img> の onerror の値は、文字列のまま React に渡りました。React は描画のときに「関数ではない」というエラーを出しました。画像の読み込みに失敗したあとにも同じ内容の例外が出て、中のコードは動きませんでした。
  • <script> は要素として入りましたが、React が「クライアントで描画したときは、コンポーネントの中のスクリプトは実行されない」という警告を出し、中のコードは動きませんでした。

ただし、<script> が動かないのは、ブラウザで描画したときだけでした。同じコンポーネントをサーバー側の renderToStaticMarkup で HTML にすると、<script> と <iframe> がそのまま出力に入りました。その HTML をブラウザで開くと、両方のコードが動きました。

text
<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="&lt;script&gt;parent.__hit(&#x27;iframeのsrcdoc&#x27;)&lt;/script&gt;"></iframe>
<script>__hit('scriptタグ')</script>
text
# 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 の後ろに置きます。

tsx
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 にしてから開いた場合も、コードは動きませんでした。

text
<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> が出したものです。

3つの書き方で同じMarkdownを表示した画面。1つ目はHTMLのタグが文字のまま出て、2つ目は壊れた画像のアイコンとリンクと空のiframeが出て、3つ目は壊れた画像のアイコンと青くないリンクの文字だけになっている。下の実行記録に、iframeのsrcdocが動いたという行が1つ出ている
同じMarkdownを3つの書き方で表示した画面。コードが動いたのは、rehype-rawだけを足したときだけ

順番が大事なのは、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のリンク」だけが段落に残りました。

tsx
export function SkipHtmlComment({ body }: { body: string }) {
  return <Markdown skipHtml>{body}</Markdown>;
}

allowedElements には、描画してよい要素の名前を並べます。unwrapDisallowed を付けると、許していない要素は、要素だけを外して中身を残します。次のコンポーネントでは h2 を許していないので、見出しが「感想」という文字だけになりました。

tsx
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 をそのまま返す関数に差し替えたコンポーネントも試しました。

tsx
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.」というエラーが出ました。

text
# 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 を通った後の値(ここでは空の文字列)が渡ってきました。

tsx
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>;
}
text
<p><a href="" target="_blank" rel="noopener noreferrer">詳しくはこちら</a></p>
ポイント

react-markdown は、何も足さなければ、HTML を文字のまま出し、安全なプロトコル以外の URL を消します。HTML を表示しないなら rehype-raw は入れず、必要に応じて skipHtml や allowedElements で絞ります。HTML を表示するなら、rehype-sanitize を rehypePlugins の最後に置きます。

​Posted by 86