Edited at

VueをSSRに乗せると容易にXSSを生み出す場合がある件について

More than 1 year has passed since last update.

はじめに

最近Vue.jsを頻繁に使用するのですが、他のSSR(サーバーサイドレンダリング)の仕組みと組み合わせる場合、容易にXSSを生み出してしまうケースが存在するので、注意喚起も兼ねて事例を紹介させていただきます。


  • 9月7日 追記を追記しました


前提


  • サーバーサイドで動的に要素をレンダリングするシステムとVue.jsを組み合わせた場合

この記事はrailsのSSRとの組み合わせで解説しますが、プレーンなPHP等、動的にHTMLをレンダリングシステムとの組み合わせでも発生します。


サンプルコード

まず、こちらのコードをご覧ください。

user.erb

<div id="app">

<div class="user">
<%= @user.name %>
</div>
<button v-on:click="registerFavorite" data-user-id="<%= @user.id %>">お気に入り登録</button>
</div>

user.js

var app = new Vue({

el: '#app',
methods: {
registerFavorite: function (e) {
// お気に入り登録のリクエストを送る
}
}
})

上記のコードは、サーバーサイドでユーザについてレンダリングした後、お気に入り登録の処理をVue.jsに委譲したコードになります。

erbに含まれる <%= %> で囲まれている部分は、rubyの任意のコードを評価して、戻り値をHTMLエスケープ後、ビューに埋め込む記法です。

今回は、ユーザ名(@user.name)とユーザID(@user.id)をHTMLに埋め込んでいます。


何が危険なのか

Railsの機能によってHTMLエスケープもされるし何が危険なのか」と思うかもしれません。ここで思い出していただきたいのが、Vueの補完機能です。

Vueには補完機能が存在し、mustache記法で任意の場所にJSを埋め込むことが可能です。

上記のサンプルコードにおいて、@user.name にmustache記法を含む値をユーザが入力した場合 (例えば @user.name{{ alert(888) }} が入力されていた場合)

<div id="app">

<div class="user">
{{ alert(888) }}
</div>
<button v-on:click="registerFavorite" data-user-id="1">お気に入り登録</button>
</div>

上記のHTMLが出力されます。

(mustache記法で使用する { } はHTMLタグではないのでエスケープされずにそのまま出力されます。)

この状態で #app に Vueをマウントすると、mustache記法を含むユーザ入力値(ユーザ名)がJSとして実行されてしまいます。

便利なフレームワークを使い強固なウェブサイトを構築したはずが、何でもできる便利なサイトと化してしまいました。 :innocent:


何がつらいのか

「Vue管轄のDOMは絶対にSSRでユーザ入力値を入れてはならない」と言うことを意識して組まなければ簡単に事故り、コストも上がります。

保守面を考えると、上記ルールをテストで担保することは難しいため、新規開発者が加わるたびに周知する必要があり、レビューコストも増大します。

Vue.jsの公式サイトに


他のライブラリや既存のプロジェクトに統合したりすることはとても簡単です。


とありますが、流石にこの状況で既存のプロジェクトへの統合はつらい。。。


対策

補完機能を無効にしましょう!

mustache記法が使えないのは少し不便ですが、脆弱性を生む恐怖やそれが安全であることを保証する工数を考えれば、無効にしたほうが全然良いですよね。

しかし、Vue2.xには補完機能を無効にする機能はありません。

issueも出ていたのでPRを投げましたが反応無し。。。(https://github.com/vuejs/vue/pull/6203)

と言うことで暫定対策を紹介します。


暫定対策

delimiterを指定する機能を使用して補完機能を無効にしましょう。(参考: https://github.com/vuejs/vue/issues/4223)

var app = new Vue({

// @note 何にもヒットしない正規表現をデリミタにして補完を無効にする。
delimiters: [ { replace: function() { return '^(?!.).' } }, { replace: function() { return '' } } ],
el: '#app',
methods: {
registerFavorite: function (user_id) {
// サーバに指定されたユーザをお気に入り登録のリクエストを送る
}
}
})

解説

delimitersに上記の指定をすることで、補完部分を抽出する正規表現が {{((?:.|\\n)+?)}} から ^(?!.).((?:.|\\n)+?) となります。この正規表現は何にもヒットしないのでVueの補完機能を無効化できます。

(参考: https://github.com/vuejs/vue/blob/e259fc306eece6be8014d2ace0258d9775971cd5/src/compiler/parser/text-parser.js#L10)


おわりに

Vueは他のプロジェクトへの統合しやすさを謳っていますが、上記の点に注意しなければ容易にXSSを生み出してしまうことに注意が必要です。

(もちろん、動的レンダリングを全てクライアントの責務にする設計や、SPAにすることで、問題を根本的に回避することも可能です。)

補完機能は、暫定対策で機能を無効にできますが、Vueのバージョンアップ時には暫定対策が有効であるか検証するコストはかかります。

早くPR( https://github.com/vuejs/vue/pull/6203 )マージされてくれ~~~~~ :pray: :pray: :pray: :pray: :pray: :pray: :pray: :pray:


追記

VueのAPIサイズを極力シンプルにするという方針からPRはリジェクト :cry:

String#replaceをモックしたパッチの動作保証を実装ごとに行うのはつらいので、Vueのプラグインを作成・公開しました。:muscle_tone1::muscle_tone1::muscle_tone1:

https://github.com/alfa-jpn/vue-disable-interpolation

https://www.npmjs.com/package/vue-disable-interpolation

上記のパッチであることは変わりないですが、Vueと結合したe2eテストを作成しており、環境変数でVueのバージョンを指定してテストを実行できるようにしています。

これにより .travis.yml にVueのバージョンを指定してpushするだけでTravisCIでテストが実行されるので、低コストである程度までの動作保証はできると思います。

少しでも保守コストが下がれば…! PRもお待ちしております!!! :pray: