MarkdownをHTMLにコンパイルするmarked0.7.0sanitizeオプションを非推奨にしています。

これはサニタイズの処理をmarkedから外す目的です。

このsanitizeオプションの代わりにDOMPurifyを利用することを推奨していますが、 DOMPurifyはブラウザとNode.js両方で使うには癖があるためちょっとややこしいです。

なぜならDOMPurifyはDOM APIに依存しているため、 Node.jsで動かす場合はjsdom使うためです。

単純にjsdomを使ってしまうとブラウザでもjsdomが含まれてしまい、ファイルサイズが巨大になってしまいます。

jsdom file size

そのため、ブラウザ向けの場合では直接DOMPurifyを使い、 Node.jsの場合はDOMPurifyjsdomを一緒に使う実装が必要になります。

この処理をちゃんとやるのは難しいため、safe-markedというライブラリを作りました。

safe-marked

safe-markedmarkedで変換した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.jsonbrowserフィールドに対応しているため、webpackなどのbrowserフィールドに対応しているライブラリはこちらのエントリーポイントを扱えるという形です。

この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-markedmarkedDOMPurifyjsdomのラッパーでしかないので、これらの依存をRenovateで自動的に追従してアップデートできるようにしています。