Markdownを変換するなら正規表現は捨てる。remark/rehypeで安全に処理する書き方
MarkdownをHTMLに変換するとき正規表現で見出しやリンクを拾うと事故る。remark/rehypeのパイプライン、rehype-sanitizeでXSS対策、目次生成、MDXとの違いを実コードで。
「Markdownを記事ページに表示するだけでしょ?正規表現でちゃちゃっとやれば終わるよ」
数年前の僕は本気でそう思っていました。^#で見出しを拾って、[...](...)でリンクを拾って、コードブロックは<pre>で囲む。半日で動くものができて、自分を天才だと勘違いしました。
事故は1週間後に来ました。あるユーザーが投稿したMarkdownに、こう書いてあったんです。
お問い合わせは <img src=x onerror="alert(document.cookie)"> まで
僕の自作変換は、このHTMLをそのまま素通しでページに出力しました。onerrorの中身がブラウザで実行され、ログインCookieが抜ける一歩手前。幸い検証環境での発覚でしたが、背筋が凍りました。Markdown変換は「文字列置換」ではなく、構文を理解して、危険な入力を無害化する処理だったんです。
この記事では、その教訓から僕がたどり着いたremark/rehypeによるMarkdown処理を、コピペで動くコードつきで書きます。
この記事の要点
- Markdownを正規表現で処理すると、コードブロック内の
##を見出しと誤認したり、危険なHTMLを素通ししたりして事故る。構文木(AST)で扱うのが正解。 - 変換の本線は
remark(Markdown担当) →remark-rehype(橋渡し) →rehype(HTML担当) →rehype-stringify(文字列化)。プラグインを差し込んで拡張する。 - raw HTMLを許すなら
rehype-rawで取り込んだ後、必ず最後に近い位置でrehype-sanitizeを通す。順番を間違えるとXSS対策が無効になる。 - 目次は
rehype-slug+rehype-autolink-headingsで自動生成。シンタックスハイライトはrehype-starry-nightかrehype-pretty-codeを使う。 - MDXはMarkdownにJSXコンポーネントを書ける別物。表示専用なら素のMarkdown+remark/rehypeで十分なことが多い。
remark/rehypeって、結局どういう仕組み?
ひとことで言うと、Markdownを「文字列」ではなく「木の形のデータ」に変換してから加工するエコシステムです。
料理に例えます。正規表現は、できあがった料理(文字列)を包丁で適当に切り分けるやり方。見た目が似ていれば中身が違っても同じように切ってしまいます。一方remark/rehypeは、まず食材を分解して(構文解析)、「これは見出し」「これはリンク」「これはコードブロック」とラベル付きの部品にしてから調理します。だから、コードブロックの中に## 見出しっぽい文字があっても、それを本物の見出しと間違えません。
この「木の形のデータ」を**AST(抽象構文木)**と呼びます。Markdown用のASTはmdast、HTML用のASTはhastという名前です。全体を束ねる土台がunifiedで、その上で動くのが次の2つです。
- remark: Markdown(mdast)を扱う係。見出し・リンク・表などを部品として読む。
- rehype: HTML(hast)を扱う係。属性のサニタイズや目次リンク付与など、HTML寄りの加工を担当する。
両者をつなぐのがremark-rehypeという橋です。unifiedの入門は公式のintroduction、ASTの考え方はsyntax treesの解説が分かりやすいです。
変換パイプラインの全体像
Markdown→HTMLの変換は、ベルトコンベアに加工機を並べるイメージです。素材(Markdown文字列)を入れて、各工程を通し、最後にHTML文字列を取り出します。
| 工程 | プラグイン | 役割 |
|---|---|---|
| 1. 解析 | remark-parse | Markdown文字列をmdastに変換する |
| 2. 拡張 | remark-gfm | 表・打ち消し線・自動リンクなどGFMに対応する |
| 3. 橋渡し | remark-rehype | mdast(Markdown)をhast(HTML)に変換する |
| 4. raw取込 | rehype-raw | 本文中の生HTMLを正式なhastノードにする(任意) |
| 5. 無害化 | rehype-sanitize | 危険なタグ・属性を落とす(セキュリティの要) |
| 6. 装飾 | rehype-slug ほか | 見出しIDや目次リンクを付ける(任意) |
| 7. 文字列化 | rehype-stringify | hastをHTML文字列に戻す |
ポイントは、工程が一方通行で、途中に好きな加工機(プラグイン)を差し込める点です。「表に対応したい」ならremark-gfm、「目次を作りたい」ならrehype-slugを足すだけ。自前のロジックを書き散らす必要がありません。
remark-rehypeの現行バージョンは11系で、mdastをhastへ変換する標準の橋です。詳しくはremark-rehypeのドキュメントを見てください。
まず動かす:Markdownを安全なHTMLにする最小コード
説明より動かすのが早いです。Node.js 18以降で、生HTML混じりのMarkdownを受け取り、サニタイズして安全なHTMLを返す最小例を作ります。
まず準備します。
mkdir md-render-demo && cd md-render-demo
npm init -y
npm pkg set type=module
npm install unified remark-parse remark-gfm
npm install remark-rehype rehype-raw rehype-sanitize rehype-stringify
次に本体(render.mjs)です。覚えるのは1点だけ。rehype-sanitizeを「危険を作りうる工程より後ろ」に置くこと。 これが守れていれば、冒頭のonerror攻撃は無害化されます。
// render.mjs : MarkdownをサニタイズしてHTMLにする最小例
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
// わざと危険なHTMLを混ぜた入力(攻撃を想定)
const markdown = [
"# こんにちは",
"",
"これは **太字** と [リンク](https://example.com) のテスト。",
"",
"お問い合わせは <img src=x onerror=\"alert(document.cookie)\"> まで",
"",
"> 引用の中の ## も見出しにはならない",
].join("\n");
// defaultSchema はGitHub相当の安全な許可リスト。
// コードのclassName(language-xxx)だけ追加で許可する。
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-/]],
},
};
const file = await unified()
.use(remarkParse) // Markdown → mdast
.use(remarkGfm) // 表や打ち消し線に対応
.use(remarkRehype, { allowDangerousHtml: true }) // mdast → hast(生HTMLは一旦保持)
.use(rehypeRaw) // 生HTMLを正式なノードに変換
.use(rehypeSanitize, schema) // ← ここで危険な属性を必ず落とす
.use(rehypeStringify) // hast → HTML文字列
.process(markdown);
console.log(String(file));
実行します。
node render.mjs
出力されたHTMLを見ると、<img>からonerror属性がきれいに消えています。<h1>や<a>、<code class="language-js">はちゃんと残る。これが「構文を理解したうえで危険だけ落とす」の意味です。正規表現で全部の<...>をエスケープすると、今度は正当なHTMLまで壊れます。remark/rehypeはその線引きをやってくれます。
サニタイズの順番を間違えると、対策が消える
ここが一番事故りやすいので、独立した節にします。rehype-sanitizeの公式が明言しているのは、**「最後の危険な処理の後にサニタイズせよ」**という鉄則です。
僕が最初にやらかしたのは、こういう順番でした。
// アンチパターン:サニタイズの後にraw HTMLを取り込んでいる
.use(rehypeSanitize) // ここで一度きれいにしても…
.use(rehypeRaw) // この後に生HTMLが復活して素通りする
.use(rehypeStringify)
rehype-rawが生HTMLをノードに戻す工程は「危険を作りうる工程」です。その後ろにサニタイズが無いと、せっかくの対策が無意味になります。正しい順番は前掲のとおり、rehype-rawの後ろにrehype-sanitize。文章で言うと当たり前に聞こえますが、プラグインの順番を入れ替えただけで穴が開くので、コードレビューでは必ずここを見ます。
そもそも生HTMLを許す必要がないなら、allowDangerousHtmlもrehype-rawも付けないのが一番安全です。remark-rehypeはデフォルトで生HTMLを無視します。「ユーザー投稿は生HTML禁止、社内記事だけ許可」のように、入力の信頼度で分けるのがおすすめです。XSSの考え方そのものはOWASPのXSS Prevention Cheat Sheetが決定版です。
目次とシンタックスハイライトを足す
実用ブログだと、見出しから目次を作りたいし、コードに色を付けたい。これもプラグインを足すだけです。
目次の下ごしらえには次の2つを使います。
rehype-slug: 各見出しにidを自動付与する(## 使い方→id="使い方")。rehype-autolink-headings: 見出しにアンカーリンク(#)を付ける。
npm install rehype-slug rehype-autolink-headings rehype-starry-night
// toc-and-highlight.mjs : 見出しIDとコードの色付けを足す
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeStarryNight from "rehype-starry-night";
import rehypeStringify from "rehype-stringify";
// "`" を3つ並べてフェンスを作る(本文に生フェンスを直書きしない)
const fence = "`".repeat(3);
const markdown = [
"# 記事タイトル",
"## はじめに",
"本文です。",
"## 使い方",
fence + "ts",
"const greet = (name: string): string => `Hello, ${name}`;",
"console.log(greet('Masa'));",
fence,
].join("\n");
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSlug) // 見出しにidを付与(目次リンクの土台)
.use(rehypeAutolinkHeadings) // 見出しにアンカーを付ける
.use(rehypeStarryNight) // コードをトークン分解して色付け
.use(rehypeStringify)
.process(markdown);
console.log(String(file));
見出しにIDが入れば、あとはそのIDを集めて<ul>を組むだけで目次になります。IDの生成規則はgithub-sluggerに揃えておくと、日本語見出しでもページ内で一貫します。ハイライトはrehype-starry-night(GitHub方式)のほか、rehype-pretty-codeもよく使われます。サーバー側で色付けしておけば、ブラウザに重いJSを送らずに済むのが利点です。
この「記事を安定して変換・公開する」基盤づくりは、CMS設計とも地続きです。自作CMSとヘッドレスCMSの判断はブログCMSは自作かヘッドレスかで僕の結論を書いたので、合わせて読むと運用像がつかめます。
実務で効く3つの使いどころ
1. ユーザー投稿・コメントの表示
掲示板やレビュー欄でMarkdownを許すなら、サニタイズは必須です。ここを正規表現で済ませると、冒頭の僕のようにonerrorやjavascript:リンクで穴が開きます。rehype-sanitizeのdefaultSchemaを土台に、必要な属性だけ追加で許可するのが定石。投稿系の安全対策の入口は初心者がまず守る5つの安全対策も参考になります。
2. ドキュメントサイトの自動目次・リンク検査
社内ドキュメントをMarkdownで書いていると、見出しの目次化と、本文中リンクの死活チェックを自動化したくなります。mdastを走査すれば、リンクノードだけを正確に抜き出せる。「[...](...)を正規表現で拾う」と、コードブロック内の例まで拾って誤検知します。
3. 記事の一括リフレッシュ
公開済み記事のdescription長やコードフェンスの言語抜けを、まとめて点検したい場面。mdastのheading/code/linkノードを見れば、見出し階層・コード言語・リンク種別を一度に集計できます。これは僕がこのブログの品質チェックに実際に使っている方法です。
MDXとの違いはどこ?
よく混同されますが、MarkdownとMDXは別物です。
| 観点 | 素のMarkdown | MDX |
|---|---|---|
| 書けるもの | テキスト+一部HTML | Markdown+JSXコンポーネント |
| 例 | **太字** | <Chart data={items} /> を本文に直接書ける |
| 処理 | remark/rehypeで変換 | MDXコンパイラでJSXに変換してから実行 |
| 向く用途 | 表示専用の記事・投稿 | インタラクティブな部品を埋めたい技術ドキュメント |
| リスク | サニタイズで概ね守れる | 任意のJSXが動くので信頼境界が重い |
ざっくり言うと、ボタンやグラフなど「動く部品」を本文に埋めたいならMDX、ただ記事を表示したいだけなら素のMarkdownで十分なことが多いです。ユーザー投稿をMDXで受けるのは、任意コード実行に近い話なので僕はやりません。MDXの正確な定義はMDX公式を読んでください。
僕がやらかした失敗3つ
正直に書きます。remark/rehypeに移行してからも、しばらくは事故りました。
ひとつ目は、冒頭の生HTML素通し。これはもう書いたとおり、サニタイズ無しの自作変換が原因でした。rehype-sanitizeを入れた瞬間に解決しました。
ふたつ目は、サニタイズで自分のスタイルまで消えたこと。classを全部落とす設定にしたら、コードのシンタックスハイライト用のlanguage-jsクラスまで消えて、色が付かなくなりました。defaultSchemaを全部禁止と勘違いしていたんです。実際は「許可リスト方式」なので、必要なクラスだけattributesに足せばいい、と気づいて落ち着きました。
みっつ目は、プラグインの順番。rehype-slugをrehype-stringifyの後ろに置いて「目次のIDが出ない」と小一時間悩みました。文字列化した後に加工しても、もう木構造はありません。パイプラインは順番が命だと、身体で覚えました。
よくある質問
Q. 正規表現でMarkdownを処理してはいけないの? ごく単純な置換(例:特定の絵文字を画像に変える)なら使う場面もあります。ただし見出し・リンク・コードの「構造」を読む処理は、コードブロック内の誤認やネスト崩れで必ず破綻します。構造を見るならASTを使ってください。
Q. remarkとrehype、両方いるの?
Markdownだけ加工して文字列をそのまま返すならremark系だけでも足ります。HTMLに変換したり、HTML属性をサニタイズしたりするならremark-rehypeでrehype側に渡します。HTMLに出すなら両方使うのが普通です。
Q. dangerouslySetInnerHTMLで出すなら安全?
いいえ。Reactは通常のテキスト埋め込みはエスケープしますが、dangerouslySetInnerHTMLはその名のとおり素通しです。そこに入れる文字列は、事前にrehype-sanitizeを通したものに限ってください。
Q. シンタックスハイライトはクライアントとサーバーどちらでやるべき?
表示が主目的ならサーバー(ビルド時)がおすすめです。rehype-starry-nightやrehype-pretty-codeでHTMLに色を焼き込めば、ブラウザに重いハイライトJSを送らずに済み、初期表示も速くなります。
Q. AstroやNext.jsでも同じ?
はい。多くのフレームワークが内部でremark/rehypeを使っていて、設定ファイルにremarkPlugins/rehypePluginsとしてプラグインを足せます。仕組みを理解しておくと、フレームワークの設定もそのまま読めます。
実際に試した結果
冒頭のonerror事故のあと、僕は「Markdownはテキスト」という思い込みを完全に捨てました。いま自作変換は1行も残っていません。
このブログでは、mdastで見出し・コード・リンクのノードを走査して、description長・コードフェンスの言語抜け・内部/外部リンクの有無を機械チェックしています。正規表現でやっていた頃は、コードブロック内のサンプルを本文と誤検知して何度も振り回されましたが、ASTに切り替えてからその手の誤検知はゼロになりました。rehype-sanitizeを最後尾近くに固定するルールを1つ決めただけで、生HTMLの不安も消えました。
教訓はシンプルです。Markdown変換は「切る」作業ではなく「分解して、危ないものだけ無害化する」作業。構文木で扱うと決めた瞬間に、事故の大半は設計で防げます。
手を動かして基盤を整えたら、運用フローやチームの権限設計まで広げたくなるはず。そのあたりは研修・導入相談で実プロジェクトに合わせて設計できます。まずは上の最小コードを動かして、自分のMarkdown処理にrehype-sanitizeを1つ足すところから始めてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。