Programming log - Shindo200

イベント参加記録とプログラミング系の雑記

RailsのCSRF対策の仕組みについて

先日、Rails で開発しているときに意図しない InvalidAuthenticityToken エラーが発生して、すごくハマってしまいました。そのときに RailsCSRF対策の仕組みについていろいろと調べましたので、ブログに残しておきます。

RailsCSRF対策

Rails が生成した ApplicationController には以下の記述があります。

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception
end

protect_from_forgery メソッドがコントローラーに記述されていると、CSRF対策が有効になるようです。protect_from_forgery メソッドについて調べていきます。

protect_from_forgery メソッド

まずは protect_from_forgery メソッドソースコードを見てみます。

rails/request_forgery_protection.rb at 79d50ce3104d2ff4a3964b12139120b85dce35e7 · rails/rails · GitHub

def protect_from_forgery(options = {})
  self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
  self.request_forgery_protection_token ||= :authenticity_token
  prepend_before_action :verify_authenticity_token, options
  append_after_action :verify_same_origin_request
end

protect_from_forgery メソッドを呼び出すと、以下の2つのメソッドがフィルターとしてコントローラーに定義されます。

ちなみに、prepend_before_action メソッドや prepend_after_action メソッドで定義したフィルターは他のフィルターよりも先に実行されます。 CSRF対策は verify_authenticity_token メソッドで行っていますので、今回は verify_authenticity_token メソッドの処理だけを見ていきます。

verify_authenticity_token メソッド

大雑把に説明すると、verify_authenticity_token メソッドはHTTPリクエストのヘッダーやボディに入っているCSRF対策用トークンの値を検証するメソッドです。検証した結果、不正だと判断した場合に InvalidAuthenticityToken エラーを発生します。(このエラーは protect_from_forgery メソッドのwithオプションに :exception を渡したときに発生します。他の値を渡したときの処理については調べていません。)

もう少し詳しく知りたいので、verify_authenticity_token メソッドソースコードを見てみます。

def verify_authenticity_token
  mark_for_same_origin_verification!

  if !verified_request?
    if logger && log_warning_on_csrf_failure
      logger.warn "Can't verify CSRF token authenticity"
    end
    handle_unverified_request
  end
end

どうやら verified_request? というメソッドがあり、このメソッドの返り値が false だったときに不正だと判断するようです。verified_request? メソッドソースコードも見てみます。

def verified_request?
  !protect_against_forgery? || request.get? || request.head? ||
    valid_authenticity_token?(session, form_authenticity_param) ||
    valid_authenticity_token?(session, request.headers['X-CSRF-Token'])
end

このメソッドでは以下の5つの項目を検証していきます。

  • 設定ファイルで config.application_controller.allow_forgery_protection を false に設定しているか
  • リクエストのHTTPメソッドがGETであるか
  • リクエストのHTTPメソッドがHEADであるか
  • セッション変数 _csrf_token の値とリクエストボディの authenticity_token の値を比較した結果、正しいと判断されるか
  • セッション変数 _csrf_token の値とリクエストヘッダーの X-CSRF-Token の値を比較した結果、正しいと判断されるか

検証した結果、1つでも当てはまる項目があったときは true を返し、当てはまる項目がなかったときは false を返すようです。本番環境では config.application_controller.allow_forgery_protection を true に設定して、データを更新するときはPOSTやPUTメソッドでリクエストを送るのが一般的ですので、最初の3つの項目に当てはまることはなさそうです。そうなると、検証を通すには「authenticity_token をフォームに埋め込んでおく」「X-CSRF-Token をリクエストヘッダーに設定しておく」のどちらかの対応を行わなければいけない……ということになります。

authenticity_token をフォームに埋め込む

まずは「authenticity_token をフォームに埋め込んでおく」方法から見ていきます。実は form_for や form_tag のヘルパーで生成されたフォームには、最初から authenticity_token が埋め込まれています。

erb

<%= form_for @person do |f| %>
  <%= f.submit %>
<% end %>

生成されるhtml

<form accept-charset="UTF-8" action="/people" class="new_person" id="new_person" method="post">
  <div style="margin:0;padding:0;display:inline">
    <input name="utf8" type="hidden" value="&#x2713;" />
    <input name="authenticity_token" type="hidden" value="NrOp5bsjoLRuK8IW5+dQEYjKGUJDe7TQoZVvq95Wteg=" />
  </div>

  <input name="commit" type="submit" value="Create Person" />
</form>

ヘルパーが自動的に authenticity_token を埋め込んでくれるので、開発者がCSRF対策のために手間をかけてコードを書かなくても大丈夫なのですね。

Ajaxでフォームの内容を送信するとき

先ほど「ヘルパーが自動的に authenticity_token を埋め込んでくれる」と言いましたが、やり方によっては authenticity_token が埋め込まれないことがあります。それは form_for などのヘルパーに remote: true オプションを付けたときです。

erb

<%= form_for @person, remote: true do |f| %>
  <%= f.submit %>
<% end %>

生成されるhtml

<form accept-charset="UTF-8" action="/people" class="new_person" data-remote="true" id="new_person" method="post">
  <div style="margin:0;padding:0;display:inline">
    <input name="utf8" type="hidden" value="&#x2713;" />
  </div>

  <input name="commit" type="submit" value="Create Person" />
</form>

form_for ヘルパーに remote: ture オプションを付けると、フォームタグに data-remote 属性が設定されます。Rails 付属の jQueryjquery-rails)では、data-remote 属性が設定されているフォームの submit イベントを実行するとAjax送信を行うようになっています。(ソースコードからそのまま引用すると記事が長くなってしまうので要約しますが、下記のような感じで書かれています。興味のある方は jquery-rails のソースコードを見てください。)

$document.delegate('form', 'submit.rails', function(e) {
  var remote = $(this).data('remote') !== undefined;

  if (remote) {
    /* Ajax送信を行う */
  }
});

Ajax送信でデータの更新を行いたいときは form_for ヘルパーに remote: true オプションを付けるのですが、これだとフォームに authenticity_token が埋め込まれません。authenticity_token がないということはCSRF対策の検証に失敗してしまう……ということでしょうか。しかし、実際にこのリクエストを送っても検証を通ってくれます。何故でしょうか。リクエストヘッダーを見てみるとわかるのですが、いつの間にかに X-CSRF-Token がリクエストヘッダーに設定されています。

リクエストヘッダー

X-CSRF-Token: NrOp5bsjoLRuK8IW5+dQEYjKGUJDe7TQoZVvq95Wteg=

authenticity_token がフォームに埋め込まれていなくても X-CSRF-Token がリクエストヘッダーに設定されていれば検証には通りますので、先ほどのリクエストは問題なく検証を通ったのですね。

Ajax送信で X-CSRF-Token を設定する

Ajax送信を使ったとき、いつの間にか X-CSRF-Token がリクエストヘッダーに設定されていました。どこで設定しているのか気になりますね。jquery-rails のソースコードを見てみます。

// Make sure that every Ajax request sends the CSRF token
CSRFProtection: function(xhr) {
  var token = $('meta[name="csrf-token"]').attr('content');
  if (token) xhr.setRequestHeader('X-CSRF-Token', token);
},

// 省略

$.ajaxPrefilter(function(options, originalOptions, xhr){ if ( !options.crossDomain ) { rails.CSRFProtection(xhr); }});

どうやら、jQuery の ajaxPrefilter メソッドAjax送信する直前に実行されるメソッド)で X-CSRF-Token を設定しているようです。

Ajax送信するときは、jquery-rails が X-CSRF-Token を自動的に設定してくれるので、開発者がCSRF対策のために手間をかけてコードを書かなくても大丈夫なのですね。

まとめ

  • Rails では以下の5つの項目の内、1つでも当てはまる項目があればCSRF対策の検証に通る
    • 設定ファイルで config.application_controller.allow_forgery_protection を false に設定している
    • リクエストのHTTPメソッドがGETである
    • リクエストのHTTPメソッドがHEADである
    • セッション変数 _csrf_token の値とリクエストボディの authenticity_token の値を比較した結果、正しいと判断される
    • セッション変数 _csrf_token の値とリクエストヘッダーの X-CSRF-Token の値を比較した結果、正しいと判断される
  • form_for などのヘルパーを使うと、authenticity_token がフォームに埋め込まれる
  • ただし、remote: true オプションを付けた場合は、authenticity_token は埋め込まれない
  • Ajax送信するときは、jquery-rails がリクエストヘッダーに X-CSRF-Token を設定してくれる
  • 開発者はCSRF対策のために特別なコードを書かなくても大丈夫なようになっている


【おまけ】Ajax送信で X-CSRF-Token が設定されない不具合

jquery-railsAjax送信しているのに、何故か X-CSRF-Token が設定されない!」という不具合が発生した場合、remote: true オプションを付けながら authenticity_token もフォームに埋め込んで対処する方法があります。以下のようにヘルパーに authenticity_token: true オプションを付けておくと、remote: true を付けていても authenticity_token がフォームに埋め込まれます。

<%= form_for @person, remote: true, authenticity_token: true do |f| %>
  <%= f.label :name %>:
  <%= f.text_field :name %>

  <%= f.submit %>
<% end %>

そもそも、何故こんな不具合が発生したのか原因はわかっていないのですが……