React

react-markdownの使い方:rehype-sanitizeとrehype-rawで安全にHTMLを描画する

react-markdownの使い方:rehype-sanitizeとrehype-rawで安全にHTMLを描画する

react-markdownは、Markdown文字列をdangerouslySetInnerHTMLを使わずにReactの要素へ変換して描画するライブラリです。生成AIの回答やCMSの本文を画面に出すとき、Markdownの中に混ざったHTMLをどこまで通すかはrehype-rawとrehype-sanitizeの組み合わせで決まります。この記事では、2026年10月時点の最新版であるreact-markdown 10.1.0を前提に、インストールからGFMの表、rehype-sanitizeの組み込み順と既定の許可範囲、サニタイズで壊れる見出しID・脚注・ハイライトの直し方までを、実際に描画した出力つきで解説します。

まとめ:react-markdown 10とrehype-sanitizeの要点

  • react-markdownは既定でMarkdown内のHTMLをエスケープし、javascript:などのURLを空にする。生HTMLを使わないならrehype-sanitizeは無くても安全。
  • 生HTMLを描画するときはrehypePlugins={[rehypeRaw, rehypeSanitize]}の順。rehype-rawだけだと<script>・<iframe>・<style>・<form>がそのまま出力される。
  • 順序を逆にするとHTMLはすべて消える(危険にはならないが、表示されない)。
  • rehype-sanitizeはidとnameにuser-content-を付ける。目次リンク・脚注リンクが切れたら、この接頭辞が原因。
  • 信頼できるシンタックスハイライトや数式のプラグインは、rehype-sanitizeの後に置く。前に置くと色付けのクラスが消える。数式はmath-inlineなどの識別クラスもschemaで許可する。
  • テーブル・タスクリスト・脚注はremark-gfmが要る。v10ではトップレベルのclassNameプロップが廃止された。

rehypeとは:remark・rehype・unifiedとreact-markdownの関係

unifiedは、文章を構文木(AST)に変換してプラグインで加工するための処理基盤です。その上でMarkdownを扱うのがremark、HTMLを扱うのがrehypeで、remarkはmdast、rehypeはhastという構文木を使います。両者をつなぐのがremark-rehypeで、mdastをhastへ変換します。

react-markdownは、このunifiedの処理をReact向けに包んだコンポーネントです。react-markdownのREADME(Architecture)は、処理の流れを次の5段階で説明しています。

  1. Markdownをmdastへパース(remark)
  2. remarkPluginsでmdastを加工(例:remark-gfm)
  3. mdastをhastへ変換(remark-rehype)
  4. rehypePluginsでhastを加工(例:rehype-raw・rehype-sanitize・rehype-slug)
  5. componentsでhastをReact要素にする

記法を増やすものはremarkPlugins、出力HTMLを変えるものはrehypePluginsに渡します。rehype-で始まるパッケージをremarkPluginsに入れても効かないのは、扱う構文木が違うためです。

react-markdownのインストールと最小構成

インストールコマンドと動作要件

npm install [email protected] [email protected] [email protected] [email protected]

この記事の実測と同じ版に固定したコマンドです。最新版を入れる場合は@以降を外します。

react-markdown 10.1.0(2025年3月7日公開)はreact 18以上をpeerDependenciesに持つESM専用パッケージで、READMEはNode.js 16との互換を保つと書いています。2026年10月19日時点のnpmの最新版は、remark-gfm 4.0.1・rehype-sanitize 6.0.0・rehype-raw 7.0.0・rehype-slug 6.0.0です。rehype-sanitizeとrehype-rawは2023年8月から版が上がっていませんが、どちらもreact-markdown 10と同じunified 11系向けで、この記事の実測もこの組み合わせで行っています。

最小コードでの描画例とServer Component

import Markdown from 'react-markdown';

export default function MarkdownView({ source }) {
  return <Markdown>{source}</Markdown>;
}

Markdownは同期のコンポーネントで、フックを使わないためNext.jsのApp RouterではServer Componentにも置けます。非同期のプラグイン(例:Shikiを使うハイライト)を使う場合、サーバーではMarkdownAsync、クライアントではMarkdownHooksに切り替えます。この2つは9.1.0(2025年2月20日)で追加され、MarkdownHooksの読み込み中表示に使うfallbackプロップは10.1.0で加わりました。

v9・v10で変わった書き方(className・urlTransform)

古いサンプルが動かない原因は主に2つです。10.0.0(2025年2月20日)のリリースでトップレベルのclassNameプロップが削除され、9.0.0(2023年9月27日)でtransformLinkUriとtransformImageUriがurlTransformに統合されました。10.1.0でclassNameを渡すと、Node.jsを--conditions development付きで動かした実測では「Unexpected `className` prop」という例外になりました。条件を付けない実行ではエラーにならず、<p>x</p>だけが出力されてクラスはどこにも付きませんでした。ラッパーにクラスを当てたいときは、<Markdown>を自前のdivで囲みます。

<div className="prose">
  <Markdown>{source}</Markdown>
</div>

remark-gfmによるテーブル・タスクリスト・脚注の表示

react-markdownは既定でCommonMarkに従うため、表・打ち消し線・タスクリスト・脚注・URLの自動リンクは解釈しません。remark-gfmをremarkPluginsに渡すと、GitHub Flavored Markdownとして描画されます。

import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

<Markdown remarkPlugins={[remarkGfm]}>{source}</Markdown>

remark-gfmなしで|a|b|の表を渡すと、出力は<p>|a|b| |-|-| |1|2|</p>という1段落の文字列になりました。remark-gfmを入れると<table><thead>…として出力されます。「react-markdownでテーブルが表示されない」ときは、まずこのプラグインの有無を確認してください。react-markdownはtableに枠線や余白を付けないので、見た目はアプリ側のCSS(Tailwind CSSなら@tailwindcss/typographyのproseなど)で整えます。

rehype-sanitizeによる生HTMLの無害化と組み込み順

react-markdown既定のHTML処理とURL制限

次のMarkdownを、プラグインなしのreact-markdown 10.1.0に渡してrenderToStaticMarkupで出力しました(React 19.3.1・Node.js v26.5.0)。

a <b>bold</b>

<img src="x" onerror="alert(1)">

<script>alert(2)</script>

[link](javascript:alert(3)) <a href="javascript:alert(4)">raw</a>

HTMLはすべて&lt;b&gt;のように文字としてエスケープされ、Markdownのリンク[link](javascript:alert(3))はhref=""になりました。URLを空にしているのは既定のurlTransform(defaultUrlTransform)で、相対URLとhttp・https・irc・ircs・mailto・xmpp以外のプロトコルを空文字にします。skipHtmlを付けると、行中の<b>や<a>はタグだけが消えて「bold」「raw」の文字が残り、独立した行の<img>と<script>は中身ごと消えました。

HTMLを描画しないならrehype-sanitizeは必須ではありません。react-markdownのREADME(Security)も既定は安全としたうえで、urlTransformの上書きやプラグイン・componentsが安全とは限らないため、rehype-sanitizeの利用を案内しています。ただし、同プラグインが検査するのはhastであり、その後に実行されるurlTransformやcomponentsの変更まで無害化するものではありません。

rehype-rawとrehype-sanitizeの組み込み順

rehypePlugins <b> <script> onerror javascript:のhref
なし 文字で表示 文字で表示 文字で表示 空(href=””)
[rehypeRaw] 太字 タグのまま出力 出力に残らず 空(href=””)
[rehypeRaw, rehypeSanitize] 太字 除去 除去 href属性ごと除去
[rehypeSanitize, rehypeRaw] タグが消え文字だけ 除去 除去 href属性ごと除去

注意したいのは2行目です。onerrorとjavascript:は出力に残りませんでしたが、<script>alert(2)</script>はそのままHTMLに出ました。サーバーで描画するページ(SSRやReact Server Components)では、このHTMLをブラウザが読み込んだ時点でスクリプトが実行されます。続けて試した追加の入力でも、<iframe srcdoc>・<style>body{display:none}</style>・外部へ送信する<form>がすべて残りました。rehype-rawのREADMEは、remark-rehypeのallowDangerousHtml(react-markdownは内部で常に有効にしている)を危険と明記しています。

4行目の逆順は、危険にはならない代わりにHTMLがすべて消えます。rehype-sanitizeの時点ではHTMLがまだ解析前の生の文字列で、それごと捨てられるためです。「rehype-sanitizeを入れたらHTMLが全部消えた」ときは、順序を疑ってください。正しい形は次のとおりです。

import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';

<Markdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
  {source}
</Markdown>

rehype-sanitizeのREADMEの原則は「最後の安全でない処理の後に置く。後ろに置いた処理は、信頼できる場合にだけ許される」です。rehype-rawのほか、利用者の入力から属性を作るプラグインもすべてrehype-sanitizeより前に並べます。rehype-sanitizeを使わずDOMPurifyでHTML文字列を無害化している場合は、3.4.17で直った3件の脆弱性と影響確認の手順をDOMPurify 脆弱性の解説記事で確かめてください。

defaultSchemaが許可するタグ・属性・プロトコル

rehype-sanitize 6.0.0の既定の設定(defaultSchema)は、GitHubのサニタイズに合わせた許可リストで、実体はhast-util-sanitizeのschema.jsです。インストールした5.0.2の値を読み出すと次のとおりでした。

キー 既定値(hast-util-sanitize 5.0.2)
tagNames 53タグ(a・img・table・details・summary・input・section など)
strip script(中身ごと削除)
clobberPrefix user-content-
clobber id・name・ariaDescribedBy・ariaLabelledBy
protocols.href http・https・irc・ircs・mailto・xmpp
protocols.src http・https
code の className language- で始まるものだけ
input type=checkbox・disabled を強制

53タグにiframe・style・form・buttonは入っていません。実測では<style>はタグが外れて中身のCSSが文字として残り、<form><input name="pw">は<input disabled="" type="checkbox" name="user-content-pw"/>に変わりました。inputはGFMのタスクリストのために許可されていて、どんな入力もチェックボックスにされます。classも原則として通らず、<div class="note">は<div>になります。

schema拡張によるclass・targetの許可設定

許可を足すときはdefaultSchemaを複製し、必要な要素の必要な属性だけを追加します。値まで指定すると、その値だけが通ります。

import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';

const schema = {
  ...defaultSchema,
  attributes: {
    ...defaultSchema.attributes,
    div: [...(defaultSchema.attributes.div ?? []), ['className', 'note']],
    a: [...(defaultSchema.attributes.a ?? []), 'target', 'rel'],
  },
};

<Markdown rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}>
  {source}
</Markdown>

この設定で<div class="note">は残り、<div class="evil">はclass=""に、<a target="_blank" onclick="x()">はonclickだけが落ちました。'*'(全要素)にclassNameやstyleを足す設定は、利用者が任意のクラスでページのUIを偽装できるようになるので避けます。許可リストの考え方は入力バリデーションとは?防げる攻撃の範囲と許可リスト設計を実装目線で解説でも整理しています。

rehype-sanitizeで壊れる箇所と直し方(実測)

rehype-sanitizeを入れると壊れやすいのは次の4か所です。どれも上と同じ環境で再現し、直し方も確かめました。

見出しIDへのuser-content-付与と目次リンク切れ

rehypePlugins={[rehypeSlug, rehypeSanitize]}の順だと、# Hello Worldは<h1 id="user-content-hello-world">になり、Markdown内の[to](#hello-world)はどこにも飛びません。これはrehype-sanitizeが意図して付ける接頭辞で、利用者が書いたidがページのwindow上の変数を上書きするDOM clobberingを防ぐためのものです。

直し方は2通りあります。本文が信頼できない入力なら接頭辞は残し、#で始まるリンクの側に接頭辞を足します(次の項のコード)。社内で書く文書だけを扱うなら[rehypeSanitize, rehypeSlug]の順にすると、IDはhello-worldのままになります。ただしその場合、見出しの文言から作ったIDはサニタイズを通らないので、利用者の投稿には使いません。

脚注IDの二重接頭辞と見出し・脚注リンクの補正

remark-gfmの脚注は、もともとid="user-content-fn-1"とhref="#user-content-fn-1"を出力します。ここにrehype-sanitizeをかけるとidだけがもう一度処理されてuser-content-user-content-fn-1になり、hrefは元のままなので、脚注の番号を押しても飛ばなくなりました。

remark-rehype側の接頭辞を空にし、#リンクにはrehype-sanitizeと同じ接頭辞を足すと、見出しと脚注の両方がそろいます。

const components = {
  a({ node, href, ...props }) {
    if (href?.startsWith('#')) {
      href = '#user-content-' + href.slice(1);
    }
    return <a href={href} {...props} />;
  },
};

<Markdown
  remarkPlugins={[remarkGfm]}
  remarkRehypeOptions={{ clobberPrefix: '' }}
  rehypePlugins={[rehypeSlug, rehypeSanitize]}
  components={components}
>
  {source}
</Markdown>

この設定で、見出しはid="user-content-hello-world"とhref="#user-content-hello-world"、脚注はid="user-content-fn-1"とhref="#user-content-fn-1"になり、どちらも対応が取れました。GitHubは逆に、URLの#を読んで接頭辞付きの要素へスクロールするスクリプトで解決しており、rehype-sanitizeのREADMEの見出しの例にそのコードがあります。

シンタックスハイライトのクラス除去と処理順

[rehypeHighlight, rehypeSanitize]の順だと、rehype-highlight 7.0.2が付けたhljs-keywordなどのクラスがすべて削られ、<span>const</span>だけが残りました。[rehypeSanitize, rehypeHighlight]にするとclass="hljs language-js"と<span class="hljs-keyword">が出力されます。rehype-sanitizeのREADMEも、サニタイズしてからハイライトする形と、ハイライトのクラスを許可したschemaで後からサニタイズする形の2つを示しています。rehype-katexなどの数式処理もサニタイズの後に置きます。ただし、入力で使うmath-inline・math-displayなどの識別クラスをschemaで許可し、数式処理まで残す必要があります。なお、クラスが出力されても色はCSSで付くため、highlight.js用のテーマCSS(例:highlight.js/styles/github.css)を別に読み込みます。

tel:リンク・data:画像の除去と許可設定

電話番号の[電話](tel:0312345678)は、プラグインなしでもhref=""になりました。既定のurlTransformがtel:を許可していないためです。urlTransformでtel:だけ通しても、rehype-sanitizeを入れるとhref属性ごと消えるので、schemaのprotocols.hrefにもtelを足します。

import Markdown, { defaultUrlTransform } from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';

const urlTransform = (url) =>
  /^tel:/i.test(url) ? url : defaultUrlTransform(url);

const schema = {
  ...defaultSchema,
  protocols: {
    ...defaultSchema.protocols,
    href: [...defaultSchema.protocols.href, 'tel'],
  },
};

<Markdown urlTransform={urlTransform} rehypePlugins={[[rehypeSanitize, schema]]}>
  {source}
</Markdown>

Base64で埋め込んだ![図](data:image/png;base64,…)も、既定のurlTransformでsrcが消えます。生成AIの出力にdata:画像が含まれる場合は、許可を足すより、画像をアップロードしてURLで参照する形に変えたほうが安全です。

HTMLの許可・制限手段の使い分け

react-markdown単体の機能と、rehypeのプラグインで止められる範囲は次のとおりです。

手段 挙動 向く場面
既定(何もしない) HTMLを文字で表示 HTMLを使わない入力
skipHtml HTMLを無視 HTMLを画面に出したくない
allowedElements 指定タグだけ残す 見出しや画像を禁止したい
disallowedElements 指定タグを削る 数タグだけ禁止したい
rehype-raw HTMLをそのまま描画 自分で書いた文書だけ
rehype-raw + rehype-sanitize 許可リストで無害化して描画 投稿・AI出力・外部データ

allowedElementsとdisallowedElementsは併用できず、unwrapDisallowedを付けると削った要素の中身だけを残します(allowedElements={['p','strong']}で見出しとリンクのタグが外れ、文字だけが残りました)。さらに細かい判定はallowElementに関数を渡します。これらはMarkdown由来の要素の出し分けで、属性やURLの無害化はしないので、HTMLを通すときの代わりにはなりません。

rehype-rawを単体で使ってよいのは、本文を書く人とプラグインの両方を信頼できる場合だけです。利用者の投稿、外部APIの応答、LLMの出力はどれも信頼できる入力に入りません。LLMの回答は参照させた外部の文書や利用者の入力に左右されるため、HTMLが混ざらない保証はありません。社内CMSや業務システムでこの種のMarkdown入力を設計する場合は、業務用・Webアプリ開発のご相談も受け付けています。XSSそのものの仕組みと対策の全体像はXSSとCSRFの違いとは?仕組み・被害・対策を実装レベルで徹底比較にまとめています。

componentsでのリンク差し替えとrehype-slugの目次生成

外部リンクの別タブ表示とrel属性の設定

componentsには、タグ名ごとに差し替えるReactコンポーネントを渡します。外部リンクだけ別タブにしてrelを付ける例です。schemaにtargetを足さなくても、componentsはサニタイズの後に動くので属性が残ります。

<Markdown
  components={{
    a({ node, href, children, ...props }) {
      // 組み込み先サイトのオリジンに置き換える
      const siteOrigin = 'https://example.com';
      let external = false;
      try {
        const destination = new URL(href ?? '', siteOrigin);
        external = ['http:', 'https:'].includes(destination.protocol)
          && destination.origin !== siteOrigin;
      } catch {}
      return (
        <a
          href={href}
          target={external ? '_blank' : undefined}
          rel={external ? 'noopener noreferrer' : undefined}
          {...props}
        >
          {children}
        </a>
      );
    },
  }}
>
  {source}
</Markdown>

コンポーネントにはnode(hastの要素)も渡されるので、上の例のように取り出してから残りをaに渡します。{...props}にnodeを残したまま渡すと、実測ではnode="[object Object]"という属性が出力されました。

rehype-slugの見出しIDを使った目次生成

見出しのIDは、自前のコンポーネントを書かずにrehype-slugで付けられます。目次は、描画後の見出しから実際のIDを読むと、user-content-の有無を気にせずに作れます。

// 描画後に見出しのIDと文言を集める
const toc = Array.from(
  document.querySelectorAll('article h2[id], article h3[id]')
).map((el) => ({ id: el.id, text: el.textContent, level: el.tagName }));

集めた配列を<a href={'#' + id}>の一覧にすれば、そのまま目次になります。固定ヘッダーで遷移先が隠れる場合は、見出しにCSSのscroll-margin-topを付け、html { scroll-behavior: smooth; }でスクロールをなめらかにします。

よくある質問

rehypeとremarkの違いは何ですか?

どちらもunifiedのプラグイン群で、remarkはMarkdownの構文木(mdast)を、rehypeはHTMLの構文木(hast)を扱います。react-markdownでは、記法を増やすものをremarkPluginsに、出力HTMLを加工するものをrehypePluginsに渡します。

react-markdownでテーブルが表示されないのはなぜですか?

既定のCommonMarkには表の記法が無いためです。remark-gfmを入れてremarkPlugins={[remarkGfm]}を指定するとtableとして出力されます。枠線などの見た目はCSSで付けます。

HTMLを使わない場合もrehype-sanitizeは必要ですか?

必須ではありません。react-markdownは既定でHTMLを文字として表示し、許可外のURLを空にします。ただし、属性を作るプラグインの後にはrehype-sanitizeを置いてください。urlTransformとcomponentsはその後に適用されるため、これらの変更は別途安全性を確認する必要があります。

rehype-rawとrehype-sanitizeはどちらを先に書きますか?

[rehypeRaw, rehypeSanitize]の順です。逆にするとHTMLがすべて消えます。信頼できるシンタックスハイライトや数式のプラグインは、さらにその後ろに置きます(数式は識別クラスをschemaで許可したうえで)。

react-markdownでclassNameが効かないのはなぜですか?

10.0.0でトップレベルのclassNameプロップが削除されたためです。10.1.0では開発用の実行条件(development)で例外になり、それ以外でもクラスは付かないので、<Markdown>をdivで囲み、そちらにクラスを付けてください。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2025.02.27 コラム トラッキングビーコン(Webビーコン)とは?仕組み・用途・確認と対策を徹底解説
  2. 2026.10.27 テックブログ Joshin(ジョーシン)不正アクセス:最大600万件の対象とサブスク初期パスワードの対処
  3. 2026.10.27 テックブログ Brotli 脆弱性:CVE-2026-111497の影響確認とNode.js・Python・.NETの更新手順
  4. 2026.07.09 テックブログ Muse Imageとは?Metaの画像生成AIの使い方・API料金(1枚0.01ドル)と日本での提供状況【2026年10月】
  5. 2026.10.21 テックブログ ユミルリンク不正アクセス第二報で延べ5,162万件:Cuenote FC利用企業と受信者の確認点

RELATED POSTS 関連記事

目次