SSTエンジニアブログ

SSTのエンジニアによるWebセキュリティの技術を中心としたエンジニアブログです。

Vue.js で XSS を作り込まないために気を付けること

はじめに

はじめまして、福岡オフィスで働いている前平です。

セキュアスカイ・テクノロジーでは、すでにいくつかのカテゴリのブログを発信していますが、技術を気軽に発信したり、エンジニアが普段の業務でどのような技術に触れているのかを紹介したりすることを目的として、新しく「エンジニアブログ」が立ち上がりました。

本記事では、最近になってようやく (汗) 検証した Vue.js でのクロスサイト・スクリプティング (XSS) について紹介します。

なお、本記事の内容は私見に基づくものであり、所属組織を代表するものではありません。

前提

本記事では Vue.js を使って XSS の脆弱性を作ってしまうようなケースを説明しますが、その他の JavaScript のライブラリ/フレームワークを使った場合でも同様のリスクがある可能性があります。

検証で利用したバージョン

  • Vue.js v2.5.16
  • (サーバーサイドのコードは PHP を使って説明しますが、PHP のバージョンや、PHP そのものはあまり関係ありませんので省略します。)

XSS がわからない場合はこちらを参考にしてみてください。

本記事では、JavaScript の alert() を使い XSS を利用いて外部からスクリプトを注入できることを説明しています。 外部から alert() を注入して実行可能であるということは、攻撃者がWebサイトに対して任意の JavaScript コードを注入できる可能性があることを意味します。 任意の JavaScript コードを注入されてしまった場合、例えば、そのWebサイト内で表示される個人情報が漏洩してしまうといった被害が考えられます。

DOM Based XSS について

JavaScript を使って動的に HTML を生成している場合、DOM Based XSS に気を付ける必要があります。 DOM Based XSS とは、DOM を通じた HTML 操作の結果、攻撃者が用意した任意のスクリプトが被害者のブラウザで実行されてしまうような脆弱性のことを指します。

と言っても、最近では React や Angular、Vue.js のような JavaScript ライブラリ/フレームワーク を使うことが多くなり、自力で DOM を操作しようとして innerHTML や document.write などを使ってしまうようなケースは少なくなってきているかと思います。 そのため、DOM Based XSS に関する知識がなくても、脆弱なコードにはなりにくくなっているかもしれません。

しかし、JavaScript ライブラリ/フレームワーク を使っていても、DOM Based XSS を作り込んでしまう危険性はありますので、DOM Based XSS について基本的なことを学んだ上で利用することが望ましいかと思います。

DOM Based XSS の参考

DOM Based XSS では、「ソース」と「シンク」という用語を使います。

「ソース」
location.hash や location.href、XMLHttpRequest.responseText などの攻撃者がスクリプトを注入する箇所

「シンク」
HTMLElement.innerHTML や document.write、eval など「ソース」から文字列を受け取り、文字列から JavaScript を生成,実行してしまう箇所

JavaScript のライブラリ/フレームワークを使うことによって、HTMLを動的に生成する際に上記のような「ソース」や「シンク」となる部分が隠ぺいされて DOM Based XSS が発生しにくくなった点もある反面、開発者が意識しなければならない「ソース」と「シンク」の幅がこれまでより広がった (もしくは変化した) ようにも思えます。

以下、Vue.js での「シンク」相当の箇所にて XSS が発生する具体例をいくつか説明していきます。

XSSのあるコード例

v-html を使って HTML を生で出力した場合の XSS の例

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.min.js"></script>
<div id="app">
  <div v-html="userInputData">
  </div>
</div>

<script>
    new Vue({
        el: '#app',
        data: {
            userInputData: '<img src="hoge" onerror=alert(1)>',
        }
    });
</script>

※ コードを短くするため全く実用的でないコードになっています...
※ userInputData には、<img src="hoge" onerror=alert(1)> のように固定の文字列が書かれていますが、これはユーザーが事前に登録した値がAjax通信などを経由して動的に設定されたものとします。

上記 PHP ファイルをサーバー上に設置してブラウザでアクセスすると、alert() が実行されてポップアップがあがることを確認できました。

なお、Vue.js が内部で innerHTML を使っているためか、<script>alert(1)</script> のように直接 scriptタグを挿入しても、alert() は実行されませんでした。 こういった場合によく使われる手法として、<img src="hoge" onerror=alert(1)> のように存在しない src を指定して、onerror のイベント経由でスクリプトを実行させるという攻撃手法があります。

公式ドキュメントに、以下のように注意書きが記載されていますので、あまり安易に v-html が使われることはないかもしれません。

任意の HTML をあなたの Web サイト上で動的に描画することは、 XSS 攻撃を招くため大変危険です。v-html は信頼済みコンテンツのみに利用し、 絶対に ユーザの提供するコンテンツには使わないでください。

API — Vue.js

しかし、ユーザーから入力された値を出力するときに、一部 HTML を含めて出力したいようなケースもあるかと思います。可能な限り、HTML の生出力以外の方法で解決した方がいいとは思いますが、どうしても必要な場合は、以下のようなライブラリを使って許容する HTML タグを限定するという方法があります。

GitHub - punkave/sanitize-html: Clean up user-submitted HTML, preserving whitelisted elements and whitelisted attributes on a per-element basis. Built on htmlparser2 for speed and tolerance

また、より安全側に倒すために iframe の sandbox属性を併用するという手法もありますが、これはまた別の記事でまとめたいと思います。

葉っぱ日記

v-bind:href を使ってURLを動的に出力した場合のXSSの例

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.min.js"></script>
<div id="app">
  <a v-bind:href="url">Link</a>
</div>
<script>
      new Vue({
        el: '#app',
        data: {
          url: 'javascript:alert(1)'
        }
      })
</script>

上記PHPファイルをサーバー上に設置してブラウザでアクセスし、「Link」をクリックすると alert() が実行されました。 開発者の意図としては、url に「http(s)://example.com/hoge」や「/hoge」のようなURLが設定されることを想定していても、攻撃者によって javascript:alert(1) のような javascriptスキームを設定された場合に、任意のスクリプトが実行されてしまいます。

このように動的にリンクを設定可能にする場合は、http あるいは https のみとなるようにスキームを限定する必要があります。

第6回 DOM-based XSS その1:JavaScriptセキュリティの基礎知識|gihyo.jp … 技術評論社

また、http や https にスキームを限定する場合でも、オープンリダイレクトの脆弱性に気を付ける必要があります。 URLとして設定されるホスト名が、遷移先として許されるものかどうかを確認するようにしてください。

Vue.js とサーバーサイドのテンプレートを混在させた場合の XSS の例

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.min.js"></script>
<div id="app">
  <?= htmlspecialchars($_GET["param"], ENT_QUOTES, "utf-8") ?>
</div>
<script>
      new Vue({
        el: '#app'
      })
</script>

PHP側では htmlspecialchars() にてHTMLエスケープは行われています。 しかし、上記の PHP ファイルをサーバー上に設置し、URLパラメータに param={{alert(1)}} を設定してアクセスすると、alert() が実行されました。

ちなみに、開発バージョンの vue.js である <script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script> を利用した場合は、param={{alert(1)}} だとalert() が実行されずに以下のエラーが発生しました。

Property or method "alert" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property.

開発バージョンの場合は、以下の値を設定することで、スクリプトを実行させることができます。

param={{constructor.constructor("alert(1)")()}}

今回のコードの例では、サーバー側(PHP)のレンダリング時には適切にHTMLエスケープされ、クライアント側(Vue.js)でも {{}} の中はHTMLタグが挿入されないように適切にエスケープされているように見えます。 しかし、サーバー側(PHP)で生成しているのはHTMLではなく、あくまでも Vue.js のテンプレートであるため、本来は Vue.js のテンプレートとしてエスケープする必要があります。

対策としては、可能な限り、サーバー側とフロントエンド側のテンプレートを混在させないような設計を考え、こういった問題が根本的に発生しないようにすることが望ましかと思います。 例えば、サーバー側は JSON を返すAPIのみとし、HTML の動的な生成はフロントエンド側のテンプレートのみで行うようにするような設計が考えられます。

既存のプロジェクトに Vue.js を導入するような場合は以下のような対策が考えられます。

  • v-pre を使う

    v-pre 配下は vue のコンパイルがスキップされますので、PHP などで動的に出力する箇所は以下のように v-pre で囲うという方法があります。

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.min.js"></script>
<div id="app">
  <div v-pre>
    <?= htmlspecialchars($_GET["param"], ENT_QUOTES, 'utf-8') ?>
  </div>
</div>
  • {{ と }} をサーバーサイドでエスケープして出力する

    既存のエスケープ関数を書き換えるか、新規で作成して全体でその関数に統一するというような方法が考えられます。

  • サーバー側から返す動的なパラメータは、一旦 JSON としてブラウザに渡して、ブラウザ側でレンダリングしてもらう

    vuejs-serverside-template-xss/fix-servervars-global.php at master · dotboris/vuejs-serverside-template-xss · GitHub

    この仕組みそのものに問題がなければ、JSON API と同等のことができるため本問題は発生しないかと思います。

上記いずれの対策にしても、既存のプロジェクトに Vue.js を導入するタイミングで、サーバーサイド側のコードの影響のある範囲を一気に変更することが難しかったり、すべての開発者に(漏れなく)強制することが難しかったりするようなケースが多いように思えます。コードレビューを行いながら、開発者一人一人に問題の本質を理解してもらいつつ、根本的に問題がなくなるような状態に近づけていく方がよさそうでしょうか。

まとめ

Vue.js のような JavaScript ライブラリ/フレームワークを使う場合には、XSS を作り込んでしまわないように以下のような点に気を付ける必要があります。(上記で触れていないものもあります)

  1. ユーザーの入力値を基に v-html を使って生の HTML を出力する場合、意図しない HTML タグが入り込まないように制限する
  2. v-bind:href などURLを動的に出力する場合は、javascript スキームを指定されないようにスキームを限定する。またオープンリダイレクトにも気を付ける
  3. フロントエンドとサーバサイドのテンプレートを混在させない
  4. Vue.compile() や Vue.component()、components などを使ってHTMLを動的に生成するような場合、ユーザーの入力値から任意のHTMLタグ、属性、JavaScript コードが入り込まないようにする
  5. 使用している JavaScript ライブラリ/フレームワーク の新しいバージョンがリリースされたときに、脆弱性の修正が含まれていないかを確認し必要に応じてアップデートする

なお、今回紹介した内容以外にも、フロントエンド側でXSSを発生させないために気を付けるべき点はあるかと思います。DOM Based XSS の「ソース」と「シンク」を意識して、常に想像を膨らませながら開発するようにしましょう。(って難しいですよね 汗)

参考サイト