メインコンテンツへスキップ
#ツール比較 約9分で読めます

Markdownプレビューを自作して痛感した、「貼られたHTMLをそのまま表示してはいけない」問題(marked+DOMPurify)

ブログ記事186本超をMarkdownで書いてきた立場でオンラインのMarkdownプレビューを自作した。一番気を使ったのは混入したHTMLをそのまま描画しないこと。marked+DOMPurifyの2段構え、img onerrorの実例、GFMと改行の方言問題まで。

このブログの記事は全部Markdownで書いている。src/content/posts/の中身はもう186本を超えていて、仕事のドキュメントやREADMEまで含めれば、たぶんプレーンな文章よりMarkdownを書いている時間の方が長い。

それだけ書いていても、「この表、貼り付け先でちゃんと描画されるんだっけ」「この改行、GitHubだと消えるんだっけ」と手が止まる瞬間は定期的にくる。毎回どこかのオンラインプレビューに貼って確かめていたが、書きかけの原稿を外部サイトに貼ることにはdiffツールを作ったときと同じ抵抗があった。だったら自分のサイトに置けばいい。そう思って作ったのがMarkdownプレビュー・エディタで、変換もプレビューもすべてブラウザ内で完結する。

作る前は「markedに食わせてinnerHTMLに入れるだけ。半日で終わる」と思っていた。実際、動くものは半日で出来た。ただ、公開ツールとして一番時間と神経を使ったのはその後で、「貼られたMarkdownに混ざったHTMLをそのままレンダリングしない」ことだった。この記事はその記録。

Markdownは仕様上、生のHTMLをそのまま書ける

見落としがちだが、Markdownの原典もCommonMarkも「文中に生のHTMLを書いてよい」仕様になっている。<details><br>を記事に直接書いたことがある人は多いはずで、あれは裏技ではなく仕様どおりの挙動だ。

そしてmarkedのようなMarkdownパーサは、この生HTMLを基本的に素通しする。markedのドキュメント自身が「出力はサニタイズしないので、DOMPurifyなどを通せ」と明記している。つまり、こういう入力を変換してそのままinnerHTMLに入れると、

これは普通の段落です。
<img src="x" onerror="alert(document.cookie)">
ここも普通の段落です。

プレビューを表示した瞬間にonerrorの中身が実行される。<script>タグはブラウザ側の仕様(innerHTML経由のscriptは実行されない)で動かないことが多いので「scriptだけ消せば安全」と思いがちだが、実際に危ないのはこの手のイベント属性の方だ。画像のsrcが解決に失敗するだけでJavaScriptが走る。

さらに手元で試して嫌だったのが、生HTMLを1文字も含まない純粋なMarkdown記法でも危ないケースがあることだった。

[ここをクリック](javascript:alert(1))

markedはこれをhref="javascript:alert(1)"のリンクとしてそのまま出力する。「HTMLタグを正規表現で消す」方式の自作サニタイズでは、この形は絶対にすり抜ける。自分のツールは自分しか使わないなら「変なものを貼らない」で済むが、検索から誰が来るかわからない公開ツールでは、入力は全部信用できない前提で設計するしかない。

対策はmarked+DOMPurifyの2段構え。自作サニタイズは書かない

結論として、変換パイプラインはこの2段にした。

const raw = marked.parse(md, { gfm: true, breaks: breaksChecked, async: false });
const safe = DOMPurify.sanitize(raw);
preview.innerHTML = safe;

markedは「Markdown→HTML変換」だけに責任を持ち、安全性はDOMPurifyに全部任せる。DOMPurifyは許可リスト方式のサニタイザで、<script>はもちろん、onerroronclickのようなイベント属性、javascript:スキームのURLをまとめて除去してくれる。さっきのimg onerrorは「onerrorだけ剥がされた普通のimg」になり、javascript:リンクはhrefが落ちる。

自分でタグの許可リストを管理する案も一瞬考えたが、やめた。XSSの回避パターン(属性の中のエンコード、SVGやMathML経由、mXSSと呼ばれるパーサの解釈差を突く系)は素人が網羅できる量ではなくて、ここは枯れた専用ライブラリに乗るのが正解だと判断した。実装面で守ったのは「サニタイズ前のHTMLを絶対にDOMに触れさせない」の一点だけで、「HTMLをコピー」ボタンで取れるHTMLもサニタイズ後のものにしてある。コピーした先(自分のブログなど)に危険な断片を持ち出させないためだ。

ひとつだけDOMPurifyのフックで手を入れた。サニタイズ後のリンクにtarget="_blank"rel="noopener noreferrer nofollow"を付けている。プレビュー内のリンクをうっかりクリックして書きかけの画面から遷移する、という地味な事故を防ぐためで、これもサニタイズ後の属性操作としてDOMPurifyのafterSanitizeAttributesでやると安全に書ける。

GFMを有効にしないと「普段のMarkdown」にならない

セキュリティの次に考えたのが方言の問題だ。普段GitHubやZenn、Qiitaで書いている「Markdownっぽいもの」は、正確には素のMarkdownではなくGFM(GitHub Flavored Markdown)で、表・タスクリスト(- [ ])・打ち消し線(~~text~~)・URLの自動リンクはGFMの拡張になる。

markedはオプションなしだとこのへんの挙動が「普段の感覚」とずれるので、gfm: trueを明示して有効にした。プレビューツールとしては「仕様に忠実」より「ユーザーが普段書いている環境に近い」ほうが正しいと思っている。表の| --- |区切りが1本足りなくて描画されない、タスクリストのつもりが箇条書きに[ ]という文字列が付いただけになる、あたりは自分も何度もやらかしてきたミスで、ツールのページには忘れたときのためのチートシートも畳んで置いてある。

一番厄介なのは改行。ツールごとに挙動が違う

書いていて一番人を戸惑わせるのは、実は表でもリストでもなく改行だと思う。Markdownの標準仕様では、Enter1回の改行は改行として表示されない。改行したければ空行を挟んで段落を分けるか、行末に半角スペース2つを置く必要がある。

英語の文章ならこれでいいのかもしれないが、日本語だと「1文ごとにEnterで改行して書く」人が多くて、GitHubのREADMEに貼った瞬間に全部が1行に連結されて驚くことになる。一方でQiitaやZennはEnter1回をそのまま改行として表示する設定で動いているし、GitHubでもIssueのコメントは改行が生きる。同じMarkdownなのに、貼る場所によって見た目が変わる。

なのでツールにはmarkedのbreaksオプションを「改行をそのまま反映」というチェックボックスで露出した。デフォルトはオン(日本語の直感に合わせた)で、GitHubのREADME向けに確認したいときだけオフにすると標準の挙動になる。トグルを切り替えた瞬間にプレビューが組み変わるので、「この原稿、READMEに貼ると改行が消える」が貼る前にわかる。地味だが、自分がこのツールで一番使っている機能はたぶんこれだ。

執筆ツールとしての細部

エディタとしての作りは既存ツールの部品をだいぶ流用した。入力はlocalStorageに自動保存されるので途中でタブを閉じても続きから書けるし、外部送信は一切ない(静的ページなのでソースを見れば確認できる)。エディタとプレビューはスクロール位置が比率で連動する。文字数と行数も入力欄の上に常時出していて、これは文字数カウントツールを作ったときの数え方(サロゲートペア対応の[...str]で数える)をそのまま持ってきた。メタディスクリプションのような字数制限のある文章を仕上げるときは、原稿用紙換算や行数の内訳まで出る文字数カウント側を使う、という住み分けにしている。

ツールバー(太字・見出し・リンク・表テンプレートの挿入)も付けたが、正直これは自分ではほぼ使っていない。記法が手に入っている人間には邪魔にならない程度の存在で、Markdownをこれから覚える人向けの補助輪という位置づけだ。

プレビューの本体は、変換ではなくサニタイズだった

出来上がったMarkdownプレビュー・エディタの構成を並べると、marked(GFM有効)→DOMPurify→innerHTML、breaksトグル、localStorage自動保存、サニタイズ済みHTMLのコピー、となる。変換そのものはライブラリを呼ぶだけの1行で、開発時間の大半は「信用できない入力をどう安全に表示するか」と「方言の差をどうユーザーに見せるか」に消えた。

Markdownは「ただのテキスト」の顔をしているが、レンダリングした瞬間にHTMLになる。プレビュー機能を自作する予定がある人は、markedでもmarkdown-itでも、パーサの後ろに必ずサニタイザを1枚挟んでほしい。それを省いたプレビューは、テキストエリアの見た目をしたXSSの入り口になる。

#Markdown #DOMPurify #XSS #GFM #自作ツール
シェア