こういう記事はハッキングの助長とGoogle先生に判断されるとアボセンスされるので封印していましたが、 具体的なコードとか書かなければ大丈夫なんじゃないかということで、 今回は表題の通り Markdown におけるクロスサイトスクリプティング(っぽい)脆弱性とその対策について書いてみることにします。
クロスサイトスクリプティング (XSS)
知らない方のために少しだけ解説。
まず最初に IPA の説明です
Webページに入力データをおうむ返しに表示している部分があると,ページ内に悪意のスクリプトが埋め込まれ,それを見たユーザとサーバ自身の両方に被害を及ぼす「クロスサイトスクリプティング」という不正の手口に利用されてしまう。
ということで、この説明でわかった方は飛ばしてくれてもいいんですが、こういう攻撃の流れを図解されてもいまいちパッとこない人はいると思うので、私なりの言葉でも説明してみます。 (っていうかサーバ自身に被害あるのか?)
XSS とは 利用者のブラウザで 意図しないスクリプト(主にJS)を実行 させられる 脆弱性です。 被害としてはセッションID(わからない方は認証情報のようなものだと思ってください)の窃取やWebページの表示崩れ(物理的な改ざんではない)を引き起こされることが挙げられます。
外部サイトやメールなどのリンク(またはフォーム)を起点に、攻撃対象サイトへ誘導することによりスクリプトが実行されるため
クロスサイト
スクリプティング と呼ばれるようになりました。
このリンクに小細工がしてあり、移動先のサイトで 実行
されるというわけです。
なお、 クロスサイトスクリプティング - Wikipedia によると
XSSの定義は新しいタイプの攻撃が見つかるたびに拡張され、サイト横断的なものでなくともXSSと呼ぶようになった
この拡張された定義においてXSS攻撃とは、攻撃者の作成したスクリプトを脆弱性のある標的サイトのドメインの権限において閲覧者のブラウザで実行させる攻撃一般を指す
と記述があるので、今では攻撃の起点が外部でなくとも 第三者が用意した意図しないスクリプトが実行される時点でXSSと言えます。
(以降、スクリプトが実行されることを 発火
ということがあります)
Markdown と XSS
この記事を見ているということはおそらく Markdown 自体の説明は不要だと思います。 マークダウンの登場によりドキュメントの表現力は格段に上がり Web アプリケーションの中で採用するケースも増えてきています。
しかしマークダウンだけではテーブルが書きづらかったり(そもそもGFMだし)、画像のサイズが指定できない といった理由により HTMLタグ を入力したいことが多々あり、 多くの Markdownパーサ の実装ではタグの入力をサポートしています。
そして、なんの対策もしないと入力された HTMLタグ はそのまま無変換で描画されてしまいます。
大抵は最低限 script タグくらいはエスケープしているようですが、スクリプトは script タグ以外でも実行できます。
具体的には
- イベントハンドラ属性
- iframe の src 属性に
javascript: ~
で指定されたスクリプト - iframe, embed の src 属性に指定した base64 エンコードされたスクリプト
- a タグの href 属性に
javascript: ~
で指定されたスクリプト - style の expression 関数 (IE8で廃止)
- img src 属性に
javascript: ~
で指定されたスクリプト- いけるらしいけど最新のchromeとfirefoxでは動かず
といった具合です。他にもあるかもしれませんが、まぁとりあえずめんどくさいのです。
ユーザ任意のマークダウンテキストをサーバに保存し、レンダリングされたテキストを攻撃者以外のユーザが閲覧できるようなシステムの場合、 (対策が取られていないと)アクセスしただけでユーザは任意のスクリプトを実行させられる恐れがあります。
- info
- 自分でスクリプトを埋め込んで自分が攻撃を受けるだけなら、まあご愁傷様ですという感じなのですが他のユーザが閲覧できるということが大きなポイントになります。
- 脅威のない脆弱性はリスクになりません。
外部起点のXSSでは、攻撃者はターゲットのユーザを外部から誘導しなければならず手間がありますが 上記のケースは該当ページにアクセスした利用者全員を攻撃できるため(脆弱性がある場合の)攻撃難易度は容易でタチが悪いです。
「利便性とセキュリティはトレードオフ」という言葉がまさに当てはまるケースだと思います。
対策
Webページの描画に不具合をもたらすようなタグや属性はエスケープするのが正当な対策です。
サーバとクライアントどちらで対策するかは議論が分かれるところですが、以下では私なりの見解と結論を書いていきます。
Server side escaping
一口にサーバサイドで対策と言っても以下の2つのタイミングが考えられます。
- DBに保存する直前
- レスポンスの描画
本来 XSS の対策は 2 のタイミングで行うのが通例です。 1 ではエスケープ漏れがあったら、危険なテキストがDBに残り続けてしまいます。
他にも理由はありますがサーバ側で対策を行うならレスポンスを描画する直前が望ましいということになります。 Webアプリケーションフレームワークを使って開発している場合、これらは意識せずに行われるので信じて任せましょう。
部分的にエスケープを無効にしたいとか、フレームワークを使わない場合は エスケープライブラリを用いることをおすすめします。
[Python] HTMLのエスケープライブラリ bleach を使ってみた
また、入力したMarkdownテキストの編集と描画をシームレスに行いたいという場合、HTML描画は クライアントサイドで行う必要が出てきます。
- info
- サーバサイドで Markdown の HTML部分だけエスケープしようとも思ったのですが、 バッククォートx3で囲まれたコードブロック内だけはエスケープしないなどの仕様を考慮すると Markdown 自体の解析も必要になり非常にめんどくさいということがわかりました。
Client side escaping
というわけで私はクライアントサイドで対策を行うのがベストだと思っています。
こんな事を言っていましたが気の所為です。
ユーザに入力させたmarkdownを表示するようなサービスを運営してる人はサーバ側でHTMLの属性をエスケープしたほうがいい
— くろ (@crohaco) July 31, 2018
パーサがscriptタグをエスケープしてくれるとは言っても、イベント属性でもXSSは起こせるし、style属性で画面表示は崩せる
JS製のMarkdownエディタはたくさんありますが、内部的に markedをパーサとして利用しているものが多いので、 これについて対策を述べていきます。
markedJS
sanitize
, sanitizer
というオプションを受け取れるのでこれを利用します。
- sanitize
- デフォルト
false
でtrue
に変えるとエスケープ処理が行われるようになります。 - これを
false
にすると後述するsanitizer
は実行されないので注意しましょう。
- デフォルト
- sanitizer
エスケープ方法を関数で指定します。
何も指定しないと マークアップ記号を 実体参照 (
>
,<
など) にしてくれるので、HTMLタグを使わないという方は未指定で充分ですし、それが最も安全です。しかし、私は HTML を使いたいので、関数を作る必要がありました。
関数を実装するに当たり次のことを理解していればOKです
- 第一引数で開始タグと終了タグを文字列で受け取る
- innerTextは受け取らない
- 例えば
<div class="aa">bb</div>
なら<div class="aa">
と</div>
で2回コールされる
というわけで関数(escape)は次のようになりました。
const deniedTagCondition = /^<\/?(script|style|link|iframe|embed|object|html|head|meta|body|form|input|button)/i const deniedAttrCondition = /^(on.+|style|href|action|id|class|data-.*)/i const escape = (txt) => { if (txt.match(deniedTagCondition) || txt.indexOf('<!') === 0 || txt.indexOf('<?') === 0 || txt.indexOf('<\\') === 0) { return '' } if (txt.indexOf('</') === 0) { return txt } let outer = document.createElement('div') outer.innerHTML = txt let el = outer.querySelector('*') if (!el) {return ''} let attrs = [] el.getAttributeNames().map(attr => { if (attr.match(deniedAttrCondition)) { el.removeAttribute(attr) return } attrs.push(`${attr}="${el.getAttribute(attr)}"`) }) return `<${el.tagName} ${attrs.join(' ')}>` } // こんな感じで指定すると防げる marked.setOptions({ sanitize: true, sanitizer: escape, })
ブラウザによっては多少不正なHTMLも解釈するようになっており、テキストベースのマッチング処理ではエスケープ漏れが発生すると考え 一旦 innerHTMLに与えDOMエレメント化した上で、不要な属性を削ってからテキストに戻すという処理をしています。
removeAttribute する理由:
このやり方は記法の揺れを吸収できる半面、一つ大きなデメリットを抱えていました。
対象タグが img, video の場合、DOM エレメント化しただけで対象コンテンツのロードが始まり、 成功したときは
onload
, 失敗したときはonerror
イベントが発火してしまうのです。現状の動作を見る限り、エレメント生成直後で属性を削ればロード完了イベントが先に発火することはなさそうです。
img src の
javascript:
は発火してないので現状対応してないのですが、コードを付け加えれば対応できます。 要件に合わせてどんどん改変して使ってください。致命的な欠陥はないはず..
試したい方はGistをCloneしてください。 markdown_xss_testmarkdown_xss_test. GitHub Gist: instantly share code, notes, and snippets.https://gist.github.com/righ/50e358076d661e8c6f3cb6979011ead5
- 安全な方
- marked_safe.html
- 無防備な方
- marked_unsafe.html
XSSの検査文字列としては以下のようなサイトを参考にすると良いと思います。
- warning
- 無防備な方にペイロード全部貼り付けると alert が止まらなくなるので注意してください
コードはこんな感じ。
ちなみに marked を使っている Markdown エディタは例えば以下のようなものがあります
webpack で ビルドすると marked のオプションが simplemde でも有効になったんですけど、 別々に読み込むと有効になりませんでした。
markdown-it は Renderer いじるとできるようです。(詳細は不明) https://github.com/markdown-it/markdown-it/blob/master/docs/security.md
以上です。おかしな点などあったらコメントかTwitterまで連絡ください。