SyntaxHighlighter

2014年6月22日日曜日

Rails で CSRF トークン検証エラーが出ることがある

Rails で CSRF トークン検証エラーが出ることがある

自分で作った Rails 製ウェブアプリで出会った話ですが、ちょっと原因に悩んだので書いておきます。

概要

Rails 製のウェブアプリのログを眺めていると、しばしば CSRF トークン検証エラーが記録されていました。ログインページでログインする際にデータを POST したときに、 CSRF トークン検証エラーが出たようです。
状況的に、外部から攻撃を受けているわけではなく、ユーザーもブラウザからアクセスしたようでした。

結局のところ、バグなどではなく、期待される挙動だったのですが、ちょっと悩んだので書いておきます。

再現手順

ブラウザ起動後の初回アクセスで、ウェブアプリのページを複数同時に開くと発生します。
「複数同時」というのがポイントです。

具体的には、以下の手順で発生します。

  1. 準備として Rails 製ウェブアプリで POST を使う複数のページを、二つブックマークに登録しておきます。
    例えば、「ログインページ」と「ユーザー登録ページ」などが該当するだろうと思います。
  2. 一旦ブラウザを閉じ、新たに開き直します。
  3. 「すべてのブックマークをタブで開く」などで、登録しておいた二つのページを同時に開きます。
  4. 開いた二つのページの少なくともどちらかでは、 POST 時に CSRF エラーが出ます。

原因や詳細

原因はセッション管理のされ方にあります。

Rails での CSRF 対応

Rails では、デフォルトで CSRF の対応がされています。
これは、セッション内に _csrf_token というキーで保存された値と、 POST 時に hidden field の authenticity_token で指定された値を比較することで、実現されています。
Ajax の場合には HTTP ヘッダ内に指定することもありますが、あまり関係ないので省略します。

要は、 CSRF トークンという、セッション毎にランダムに生成される値が、 POST で渡されてくる値と一致しているかを見ています。

セッションと Cookie

Rails では、セッションは URL Rewriting などではなく、デフォルトで Cookie を用いて実現されています。
また、 Cookie の有効期限はブラウザ終了までとなっています。

よって、デフォルトの設定では、セッションはブラウザを閉じたタイミングで破棄されます。つまり、ブラウザを開いた後の初回アクセス時に新規にセッションが生成されることになります。
この結果、 CSRF トークンは、ブラウザを開いた後の初回アクセス時に生成されます。
二回目以降のアクセスでは、セッション内に保存されている作成済みの CSRF トークンが使われます。

CSRF トークン検証エラーが起こる仕組み

以上のことから、複数のページを同時に開くと、以下の原理で CSRF トークン検証エラーが起こります。

  1. ブラウザを開きます。
  2. Rails 製ウェブアプリの複数のページを同時に開きます。
    仮に、ページ A, B とします。
  3. ページ A を GET する際に、新規にセッションが開かれ、それに伴い CSRF トークンが生成されます。
    このとき、ページ A 内の hidden field には、同じ CSRF トークンが設定されており、整合性がとれた状態になっています。
  4. これとほぼ同時に、ページ B が GET されます。
    ページ A の GET が完了してからであれば、ページ A で開かれたセッションが使われますが、ほぼ同時に開いた場合は、ページ A の GET は完了していないので、新規に別のセッションが開かれ、別の CSRF トークンが生成されます。
    ページ B 内の hidden field には、こちらの CSRF トークンが設定されることになります。
    この結果、ページ A の hidden field とページ B の hidden field には、別の CSRF トークンが authenticity_token として設定されることになります。
  5. この状態で、ページ A において POST をします。
    すると、セッションはページ B で開かれたものが使われます。
    これは、セッションが Cookie で管理されているためで、 Cookie は後から設定されたもので上書きされるためです。
  6. その結果、ページ A の hidden field の CSRF トークンと、セッション内の CSRF トークン (ページ B 由来のもの) は、異なるものとなり、 CSRF トークンの検証でエラーとなります。

最初は、どこかから CSRF 攻撃を受けているのかと思って、焦りました。
セキュリティ強度を下げずに回避するのは難しいかな。