vue + typescriptで作っているフロントエンドアプリにmarkdown editorを導入したので少し紹介します。
XSS対策をちゃんとできるかという点に着目しつつ、mavonEditorEasyMDEを試して最終的にEasyMDEの方を採用しました。

mavonEditor

2020年6月時点でstar数も3.9kあり実績十分ぽかったのでまずはこれを試しました。

導入手順

こちらをベースにやっていくわけですが、typescript+開発時はnode_modules・本番はCDNという前提で導入手順を記載しておきます。

ライブラリをインストールし、

npm install mavon-editor --save-dev

mavonEditor.tsに以下のように記述します。cssはCDNから読み込む予定なのでここではimportしません。

import Vue from 'vue'
import mavonEditor from 'mavon-editor'

Vue.use(mavonEditor)

app.tsではこのmavonEditor.tsを読み込んでnew Vue()します。直接関係ないimport文などは省略してます。

import '../plugins/mavonEditor'

new Vue({
  render: (h: CreateElement): VNode => h(App),
}).$mount('#app')

次に、webpack.config.jsにmavon-editorは外部から読み込む設定を追加します。

  externals: {
    'mavon-editor': 'MavonEditor'
  }

さらに、本番用にhtmlでCDNからCSSとスクリプトを読み込んでおきます。

<html lang="en">
  <head>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.9.0/github-markdown.min.css" rel="stylesheet">
  </head>
  <body>
    <script defer src="https://unpkg.com/mavon-editor@2.9.0/dist/mavon-editor.js"></script>
    <script defer src="app.js"></script>
  </body>
</html>

MarkdownEditor コンポーネントを作ります。ここではエディタ機能だけを記載してます。
実際に使うときは、@saveイベントで入力文字列やHTMLを取得して、どこかに保存したりemitを使って親コンポーネントの関数を呼び出したりなどの実装が必要です。

<template>
  <div>
    <mavon-editor
      v-model="body"
      :toolbars="toolbarsOption"
      language="en"
    />
  </div>
</template>

import { Component, Prop, Vue } from 'vue-property-decorator'

@Component
export default class MarkdownEditor extends Vue {
  @Prop() private body!: string
  private toolbarsOption = {
    undo: true,
    redo: true,
    bold: true,
    underline: true,
    // ここにツールバーに表示したいアイコンを列挙
  }
}
</script>

あとは好きなところにMarkdownEditorコンポーネントを差し込むだけです。とても簡単ですね。

<template>
  <div>
    <MarkdownEditor :body="body" />
  </div>
</template>

<script lang="ts">
import MarkdownEditor from '../components/MarkdownEditor.vue'
@Component({
  components: { MarkdownEditor },
})
export default class Hoge extends Vue {
  private body = ''
</script>

XSS対策

導入は簡単なのですが、npm installした際にfound 1 high severity vulnerabilityと言われます。
npm auditで詳細を確認するとCross-Site Scriptingの脆弱性が報告されているようです。

┌───────────────┬───────────────────────┐
│ High                    │ Cross-Site Scripting                  │
├───────────────┼───────────────────────┤
│ Package                 │ mavon-editor                          │
├───────────────┼───────────────────────┤
│ Patched in              │ No patch available                    │
├───────────────┼───────────────────────┤
│ Dependency of           │ mavon-editor [dev]                    │
├───────────────┼───────────────────────┤
│ Path                    │ mavon-editor                          │
├───────────────┼───────────────────────┤
│ More info               │ https://npmjs.com/advisories/1169     │
└───────────────┴───────────────────────┘

More Infoに書いてある[https://npmjs.com/advisories/1169] にアクセスすると、github issue へのリンクが貼ってあって、なにやらまだ解決してない模様です。

実際に、markdown editorのtextareaに<img onerror=alert() src=1>を貼り付けてみると見事にscriptが実行されます。これは対策が必要だということで、二つ考えました。

mavonEditorが提供しているxss-optionsを使う

mavonEditorはxss-optionsを提供しています。documentにあまり記載がないのですが、入力値をjs-xssを使ってサニタイズしてくれます。js-xssはこのチートシートのXSSには対応しているようなので、自前で何かやるよりは良さそうです。

設定方法は簡単で以下のようにするだけです。

<mavon-editor xss-options="{}" />

指定できるオプションはこちらを参考にすると、サニタイズ対象外にするタグのホワイトリストを登録したりすることができるみたいですが、とりあえずデフォルトのままで試してみます。

実際に前述のチートシートの内容をそのまま貼り付けて実行してみると、以下の二つのパターンでエラーが発生しました。

<IMG SRC=/ onerror="alert(String.fromCharCode(88,83,83))"></img>
<img src="/" =_=" title="onerror='prompt(1)'">

この二つについても、ぱっと見issueには上がってませんでしたが、開発も続いているようなのでいずれ修正されることを期待しても良いかもしれません。

で、これで完了としたいところですが、mavonEditorのxss-optionsには欠陥があります。
mavonEditorは入力値に対してサニタイズをして入力値を置き換えてしまうため、入力したそばから入力値が変換されるという謎動作が発生してしまいます。
たとえば<を入力しようとすると<が入力されてしまいます。一般ユーザから見たらバグと取られそうです。。

このサニタイズ処理をプレビュー時や@changeや@saveのイベントで取得できるHTMLにだけ適用するオプションを探したのですが見当たりません。
ソースコードのこのへんを見ても、入力値そのものを書き換えてしまうようです(涙)

というわけで、2020年6月時点のxss-optionsの仕様だと厳しいと判断し、xss-optionsを使うのは諦めました。
とはいえ、このコミットをみるとxss-optionsの実装変更は2020年4月に入ったばかりのようなので、今後改善されるかもしれません。

xss-optionsを使わないで、自前でどうにかする

xss-optionsを使わない場合、リアルタイムプレビューでHTMLが生成されスクリプトが実行されるのが邪魔です。
なので、プレビューをOFFにするオプションを探したのですがtoolbarからpreviewアイコンを消しただけでは特に動作も変わらず、ソースコードのこのへんからたどっていっても、プレビュー用のHTML描画を止めることはできそうにありません。また、プレビュー用のHTMLの内容を変更する関数を定義するためのフックもなさそうです。

というわけで、こちらも諦めました。

結論

結局、XSSチートシートの内2件は対応できてないこと、現状ではxss-optionsを使うと入力値そのものがサニタイズされてしまうこと、が気になるのでmavonEditorの導入は見送ることにしました。

EasyMDE

最初にSimpleMDEを見つけました。これはエディター機能もありpreview時のHTMLのカスタマイズもできそうなのですが、如何せん開発が止まってしまっています。
もう少し探していると、SimpleMDEをforkしたEasyMDEを発見しました。こちらはstar数こそ653と少ないですが、ガンガン開発が進んでるのが良いです。機能もSimpleMDEをforkしているだけあって問題なさそうなので、こちらを試してみることにしました。

導入手順

こちらも、typescript+開発時はnode_modules・本番はCDNという前提で導入手順を記載しておきます。

EasyMDE、サニタイズ用のDOMPurify、HTML変換用のmarkedをインストールします。

npm install easymde --save-dev
//  サニタイズ用
npm install dompurify --save-dev
npm install @dompurify/marked --save-dev
// HTML変換用
npm install marked --save-dev
npm install @types/marked --save-dev

まず、webpack.config.jsにeasymdeは外部から読み込むよう設定を追加します。

  externals: {
    easymde: 'EasyMDE',
    DOMPurify: 'DOMPurify',
    marked: 'marked'
  }

さらに、本番用にhtmlでCDNからCSSとスクリプトを読み込んでおきます。
markdown用のCSSはgithubののものを利用させていただくことにします。

<html lang="en">
  <head>
    <link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css">
  </head>
  <body>
    <script defer src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
    <script defer src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.11/purify.min.js"></script><!-- サニタイズ -->
    <script defer src="https://cdnjs.cloudflare.com/ajax/libs/marked/1.1.0/marked.min.js"></script><!-- HTML変換 -->
    <script defer src="app.js"></script>
  </body>
</html>

また、tsconfig.json"allowSyntheticDefaultImports": trueを指定します。

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
  }
}

これを設定しないと、実際にeasymdeをimportしようとする以下のようなエラーが発生します。

TS1259: Module '"/xxxx/xxxx/node_modules/easymde/types/easymde"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

そして、本命の MarkdownEditor コンポーネントを作ります。

<template>
  <div>
    <textarea id="easymde-area" />
    <button @click="handleSaveBtn">save</button>
  </div>
</template>

import { Component, Prop, Vue } from 'vue-property-decorator'
import EasyMDE from 'easymde'
import DOMPurify from 'DOMPurify'
import * as marked from 'marked'

@Component
export default class MarkdownEditor extends Vue {
  @Prop() private body!: string
  private html = ''
  private mde: EasyMDE | null = null

  private mounted(): void {
    const target: HTMLElement | null = this.$el.querySelector(
      'textarea#easymde-area'
    )
    if (target !== null) {
      const config: EasyMDE.Options = {
        element: target,
        initialValue: this.body,
        autofocus: true,
        spellChecker: false,
        previewClass: ['editor-preview', 'markdown-body'],
        renderingConfig: {
          singleLineBreaks: true,
          codeSyntaxHighlighting: false,
        },
        toolbar: [
          'bold',
          'italic',
          'strikethrough',
          'heading',
          'code',
          'quote',
          'unordered-list',
          'ordered-list',
          'table',
          'horizontal-rule',
          'preview',
        ],
        renderingConfig: {
          sanitizerFunction: (renderedHTML: string): string => {
            return this.sanitizeHtml(renderedHTML)
          },
        },
      }
      this.mde = new EasyMDE(config)
    }
  }

  private sanitizeHtml(html: string): string {
    const sanitizedHtml: string = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: [
        'b',
        'li',
        'ol',
        'ul',
        'p',
        'strong',
        'em',
        'del',
        'h1',
        'h2',
        'h3',
        'h4',
        'h5',
        'h6',
        'pre',
        'code',
        'blockquote',
        'table',
        'thead',
        'tbody',
        'tr',
        'th',
        'td',
        'hr',
      ],
    })
    return sanitizedHtml
  }

  handleSaveBtn(): void {
    if (this.mde !== null) {
      this.body = marked(this.mde.value())
      this.html = this.sanitizeHtml(marked(this.body))
    }
  }
}
</script>

ポイントは以下です。

  • プレビュー時とHTML変換時にサニタイズ。詳細はXSS対策の項に記載
  • targetのtextareaがDOMに追加されている必要があるので、mounted()でeasyMDEを初期化
  • saveボタンを用意して、エディタに入力した文字列をHTMLに変換
  • 日本語はほぼspell checkエラーになるので(背景色ピンク)、spellChecker: falseを指定してspell checkを無効化
  • previewClassには、easyMDEのeditor-preview classと、githubのmarkdown-body classの両方を指定(editor-previewを指定しないと表示がおかしくなる)

あとは好きなところにMarkdownEditorコンポーネントを差し込むだけです。

<template>
  <div>
    <MarkdownEditor :body="body" />
  </div>
</template>

<script lang="ts">
import MarkdownEditor from '../components/MarkdownEditor.vue'
@Component({
  components: { MarkdownEditor },
})
export default class Hoge extends Vue {
  private body = ''
</script>

XSS対策部分を除くと、導入の手間自体はMavonEditorとほとんど差はありませんでした。

XSS対策

MavonEditorではできなかったPreview用のHTMLに対してサニタイズを行うためのsanitizerFunctionというフックが用意されていますのでその中でサニタイズを行うことにしました。
サニタイズを自前で実装するのは大変なので、ライブラリを導入しました。js-xssDOMPurifyを検討したのですが、js-xssの方は、XSSのチートシートの内容で2件ほどエラーが出たので、DOMPurifyを採用しています。
単純にDOMPurify.sanitize()すると、全てのタグがサニタイズされて、マークダウンとしての機能を果たさなくなってしまうため、XSSの対象にならない安全なHTMLタグはサニタイズ対象から除外するとともに、ツールバーにもアイコンを表示するようにしました。(<a><img>はXSSの対象になりうるので、除外しています)
実装後、チートシートの内容を貼り付けてプレビューしても、1件もエラーは発生しませんでした。

HTML変換

EasyMDEはMavonEditorと違ってHTMLに変換した値を受け取ることはできないので、saveボタンを用意して、ボタンクリック時に、markedを使ってHTMLに変換するようにしました。この際、プレビュー時と同様にサニタイズをしています。

結論

EasyMDEはプレビュー時のXSS対策をちゃんとできるので、安心して使えます。markdown editorとしての機能もMavonEditorと遜色ないので、こちらを採用することしました。