markedで安全にMarkdownからHTMLを生成するsafe-marked
MarkdownをHTMLにコンパイルするmarkedは0.7.0でsanitize
オプションを非推奨にしています。
これはサニタイズの処理をmarked
から外す目的です。
このsanitize
オプションの代わりにDOMPurifyを利用することを推奨していますが、
DOMPurifyはブラウザとNode.js両方で使うには癖があるためちょっとややこしいです。
なぜならDOMPurifyはDOM APIに依存しているため、 Node.jsで動かす場合はjsdom使うためです。
単純にjsdomを使ってしまうとブラウザでもjsdomが含まれてしまい、ファイルサイズが巨大になってしまいます。
そのため、ブラウザ向けの場合では直接DOMPurifyを使い、 Node.jsの場合はDOMPurifyとjsdomを一緒に使う実装が必要になります。
この処理をちゃんとやるのは難しいため、safe-markedというライブラリを作りました。
safe-marked
safe-markedはmarkedで変換したHTMLを自動的にDOMPurifyでサニタイズするライブラリです。
この際にブラウザの場合はDOMPurifyを直接使い、 Node.jsの場合はjsdomを使うようにエントリーポイントを分けています。
Does safe-marked always include jsdom?
No. safe-marked has two type of entry point. https://github.com/azu/safe-marked#does-safe-marked-always-include-jsdom
そのため、利用者は意識せずに必要最小限のライブラリを使えるようになっています。
技術的にはpackage.json
のbrowser
フィールドに対応しているため、webpackなどのbrowser
フィールドに対応しているライブラリはこちらのエントリーポイントを扱えるという形です。
- Resolve | webpack
- defunctzombie/package-browser-field-spec: Spec document for the ‘browser’ field in package.json
このbundleサイズの最適化によってsafe-markedはブラウザで使うと10kbちょっとになります。 (jsdomを使うと700kbを超えます)
package size minified gzipped
safe-marked 90.15 KB 39.36 KB 13.82 KB (browser bundle size)
marked@0.7.0 45.05 KB 23.87 KB 7.87 KB
dompurify@1.0.11 45.21 KB 15.3 KB 5.99 KB
# Other Markdown library
markdown-it@9.0.0 325.52 KB 92.69 KB 32.77 KB
showdown@1.9.0 157.28 KB 71.06 KB 23.55 KB
使い方は単純で、次のようにMarkdownからサニタイズ済みのHTMLを出力できます。
import { createMarkdown } from "safe-marked";
const markdown = createMarkdown();
const html = markdown(`<script>alert(1)</script>
<iframe src="https://example.com"></iframe>
This is [XSS](javascript:alert(1))`);
// sanitized by default
assert.strictEqual(html, `
<p>This is <a>XSS</a></p>
`);
markedとDOMPurifyのオプションもそれぞれ渡せるようになっています。
次のようにcreateMarkdown
に対してそれぞれのオプションを渡せます。
import { createMarkdown } from "safe-marked";
const markdown = createMarkdown({
// same options for https://marked.js.org/#/USING_ADVANCED.md
marked: {
headerIds: false
},
// same options for https://github.com/cure53/DOMPurify
dompurify: {
ADD_TAGS: ["iframe"]
}
});
const html = markdown(`# Header
<iframe src="https://example.com"></iframe>
This is [CommonMark](https://commonmark.org/) text.
`);
assert.strictEqual(html, `<h1>Header</h1>
<iframe src="https://example.com"></iframe>
This is [CommonMark](https://commonmark.org/) text.
`);
おわりに
4月27日に出版されるJavaScript Primerでは、「ユースケース: Ajax通信」の章の「HTML文字列をエスケープする」というセクションで次のように書いています。
多くのViewライブラリは内部にエスケープ機構を持っていて、動的にHTMLを組み立てるときにはデフォルトでエスケープをしてくれます。 または、エスケープ用のライブラリを利用するケースも多いでしょう。 今回のように独自実装するのは特別なケースで、一般的にはライブラリが提供する機能を使うのがほとんどです。
– https://jsprimer.net/use-case/ajaxapp/display/#escape-html
個人的には、このようなHTML文字列を出力するライブラリはデフォルトがエスケープされている方がいいと思っています。 または、ライブラリ自体にエスケープするプラグインが用意されているなどされているのがいいと思います。
ユーザー入力としてMarkdownを受け付けるサイトの場合は必然的にエスケープやサニタイズの処理が必要になります。 そのため、エスケープの仕方が難しいと単純にエスケープをしないサイトが増えてしまう気がします。
実際にmarked
を使っていてsanitize
オプションを利用してなかったり、Markdownの変換結果であるHTMLが単純にサニタイズされてないためにXSSが発生してるウェブサービスを何度か見つけて報告しています。
また、ブラウザでも現実的(ファイルサイズや利用数的)に利用できるHTMLのサニタイザーは実質DOMPurifyだけだと思います。
先ほども書いたようにDOMPurifyはブラウザとNode.jsどちらでも使えるようにするのは簡単とはいいにくいです。
safe-markedを作った理由は、それを簡単にするためです。
safe-markedはmarked、DOMPurify、jsdomのラッパーでしかないので、これらの依存をRenovateで自動的に追従してアップデートできるようにしています。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。