RailsのCSRF保護を詳しく調べてみた(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

RailsのCSRF保護を詳しく調べてみた(翻訳)

現在Railsを使っていればCSRF保護を使うことがあるでしょう。この機能はRailsのほぼ初期から存在し、即座に導入して開発を楽にできるRailsの機能のひとつです。

CSRF(Cross-Site Request Forgery)を簡単に説明すると、悪意のあるユーザーがサーバーへのリクエストを捏造して正当なものに見せかけ、認証済みユーザーを装うという攻撃手法です。Railsでは、一意のトークンを生成して送信のたびに真正性を確認することでこの種の攻撃から保護します。

最近私がUnbounceのある機能を使ったとき、CSRF保護と、CSRF保護をクライアント側のJavaScriptリクエストでどう扱うかについて考慮が必要になりました。そのとき、自分がCSRF保護についてほとんど何も知らないどころか、CSRFが何の略語なのかも知らないことに気づきました。

そこで私は、Railsコードベースでこの機能がどのように実装されているかを詳しく調べることにしました。本記事では、RailsでのCSRF保護の動作を追ってみました。レスポンスごとのトークンが最初にどうやって生成されるか、およびサーバーへのリクエストの真正性のバリデーションについても解説いたします。

基本

CSRFには2つの要素で構成されます。最初にサイトのHTMLに一意のトークンを埋め込みます。これと同じトークンはセッションcookieにも保存されます。ユーザーがPOSTリクエストを送信するときに、HTMLに埋められていたCSRFトークンも一緒に送信されます。Railsはページのトークンとセッションcookie内のトークンを比較し、両者が一致することを確認します。

CSRF保護の利用法

Rails開発者はCSRF保護を無料で利用できます。最初に、application_controller.rbファイルでCSRF保護をオンにする以下の1行を有効にします。

protect_from_forgery with: :exception

次に、application.html.erbに次の1行を追加します。

<%= csrf_meta_tags %>

これでおしまいです。この機能は長年Railsに搭載されているので、開発者はこれを利用するかどうか決めるだけでよいのです。しかしこの機能はどのように実装されているのでしょうか?

生成と暗号化

まずは#csrf_meta_tagsから調べてみましょう。これはHTMLに真正性トークンを埋め込むシンプルなビューヘルパーです(gist: csrf_helper.rb)。

# actionview/lib/action_view/helpers/csrf_helper.rb

def csrf_meta_tags
  if protect_against_forgery?
    [
      tag("meta", name: "csrf-param", content: request_forgery_protection_token),
      tag("meta", name: "csrf-token", content: form_authenticity_token)
    ].join("\n").html_safe
  end
end

csrf-tokenタグにご注目ください。すべてのマジックはここで起きます。tagヘルパーは#form_authenticity_tokenを呼んで実際のトークンを取り出します。そしてActionControllerのRequestForgeryProtectionモジュールに進むと面白くなってきます。

RequestForgeryProtectionモジュールは、CSRF関連の一切を取り扱います。中でも有名なのはApplicationControllerで見かける#protect_from_forgeryです。これはリクエストごとにCSRFバリデーションをトリガするフックを設定し、リクエストの真正性を照合できなかった場合のレスポンスを設定します。この他にもCSRFトークンの生成/暗号化/復号化も担当します。このモジュールはスコープが小さい点が気に入りました。ビューヘルパーを別にすれば、CSRF保護の実装は1ファイルに収まっています。

続いて、CSRFトークンがHTMLに達するまでを詳しく見てみましょう。
#form_authenticity_tokenは、セッション自身を含む任意のオプションパラメータを#masked_authenticity_tokenに渡すシンプルなラッパーメソッドです(gist: request_forgery_protection.rb)。読みやすさのためコードの一部を省略しています。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

# トークンの値を現在のセッションに設定
def form_authenticity_token(form_options: {})
  masked_authenticity_token(session, form_options: form_options)
end

# リクエストごとに異なる真正性トークンのマスキング版を作成する
# マスキングはBREACHなどのSSL攻撃の緩和のため
def masked_authenticity_token(session, form_options: {}) # :doc:
  # ...
  raw_token = if per_form_csrf_tokens && action && method
    # ...
  else
    real_csrf_token(session)
  end

  one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
  encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
  masked_token = one_time_pad + encrypted_csrf_token
  Base64.strict_encode64(masked_token)
end

Rails 5でフォームごとのCSRFトークンが導入されたため、masked_authenticity_tokenメソッドはやや複雑になっています。本記事では本来の実装である「リクエストごとに1つのCSRFトークンがmetaタグに達する」ところを追うことにします。この場合、上のelse分岐で#real_csrf_tokenの戻り値にraw_tokenが設定されます。

#real_csrf_tokensessionを渡す理由がおわかりでしょうか。このメソッドは実際には2つの動作を実行するからです。1つは暗号化されていない生トークンの生成、もう1つはトークンのセッションcookieへの埋め込みです(gist: equest_forgery_protection.rb)。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

def real_csrf_token(session) # :doc:
  session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
  Base64.strict_decode64(session[:_csrf_token])
end

このメソッドは最終的に、アプリのレイアウトの#csrf_meta_tagsが呼び出されると呼び出されることを思い出しましょう。これは昔ながらのRailsマジックです。この賢い副作用によって、セッションcookieのトークンがページのトークンと一致することが保証されます。保証される理由は、ページのトークンのレンダリングが行われるときには必ず同じトークンがcookieに挿入されるからです。

とにかく、#masked_authenticity_tokenの最後の方を見てみましょう(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
  one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
  encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
  masked_token = one_time_pad + encrypted_csrf_token
  Base64.strict_encode64(masked_token)

ここでは暗号化が行われます。セッションcookieにはトークンを挿入済みなので、このメソッドはプレーンテキストHTMLで使われるトークンを返す作業にかかります。ここではいくつかの点に注意します(主にSSL BREACH攻撃の緩和のためですが、ここでは立ち入りません)。Rails 4以降はセッションcookie自体を暗号化するようになったため、セッションcookieに含めるトークンそのものは暗号化されない点にご注目ください。

最初に、生トークンの暗号化で使うワンタイムパッドを生成します。ワンタイムパッドは、長さの揃った平文メッセージをランダム生成キーで暗号化する手法で、メッセージの復号には同じキーが必要です。「ワンタイム(1回限り)」と呼ばれる理由は、メッセージごとに異なるキーを用い、利用後は破棄されるからです。Railsでは、CSRFトークンを新しく作成するたびに新しいワンタイムパットを生成し、平文トークンをビットごとのXOR操作で暗号化するためにこの機能を実装しています。このワンタイムパッド文字列は暗号化文字列の前に追加され、HTMLで使えるようBase64でエンコードされます。

CSRFトークン暗号化の仕組みの概要を図示します。デフォルトのトークンは長さは32文字ですが、ここでは12文字にしています。

操作が完了すると、マスクされた真正性トークンはスタックに戻され、アプリでレンダリングされたレイアウトに達します(gist: index.html)。

<!-- index.html -->
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="vtaJFQ38doX0b7wQpp0G3H7aUk9HZQni3jHET4yS8nSJRt85Tr6oH7nroQc01dM+C/dlDwt5xPff5LwyZcggeg==" />

復号と照合

CSRFトークンの生成と、トークンがHTMLとcookieに達するまでの解説が終わりましたので、次はRailsへのリクエストのバリデーションを見てみることにしましょう。

ユーザーがサイトにフォームを送信すると、フォームの他のデータとともにCSRFトークンが送信されます(デフォルトのparam名はauthenticity_tokenです)。トークンは、HTTPヘッダーX-CSRF-Tokenでも送信できます。

先ほどApplicationControllerに以下を追加したことを思い出しましょう。

protect_from_forgery with: :exception

この#protect_from_forgeryメソッドは、すべてのコントローラアクションのライフサイクルの途中にbefore-actionを追加します。

before_action :verify_authenticity_token, options

このbefore_actionで、リクエストのparamsやヘッダーにあるCSRFトークンと、セッションcookieとの比較を開始します(gist: request_forgery_protection.rb)。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

def verify_authenticity_token # :doc:
  # ...
  if !verified_request?
    # エラー処理 ...
  end
end

# ...

def verified_request? # :doc:
  !protect_against_forgery? || request.get? || request.head? ||
    (valid_request_origin? && any_authenticity_token_valid?)
end
照合が成功する場合のフローに注目できるよう、一部のコードを省略しています。

いくつかの管理系タスクを実行後(HEADリクエストやGETリクエストなどの照合は不要です)、#any_authenticity_token_valid?の呼び出しで本格的な照合プロセスが開始されます(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def any_authenticity_token_valid? # :doc:
  request_authenticity_tokens.any? do |token|
    valid_authenticity_token?(session, token)
  end
end

リクエストはトークンをフォームのparamsまたはヘッダーとして渡すことがあるので、Railsではいずれかのトークンがセッションcookie内のトークンと一致することだけが求められます。

#valid_authenticity_token?はそこそこ長いメソッドですが、要するに#masked_authenticity_tokenの逆操作を行って復号し、トークンを比較するだけです(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def valid_authenticity_token?(session, encoded_masked_token) # :doc:
  # ...

  begin
    masked_token = Base64.strict_decode64(encoded_masked_token)
  rescue ArgumentError # encoded_masked_token の Base64は無効
    return false
  end

  if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
    # ...

  elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
    csrf_token = unmask_token(masked_token)

    compare_with_real_token(csrf_token, session) ||
      valid_per_form_csrf_token?(csrf_token, session)
  else
    false # 不正なトークン
  end
end

最初に、Base64でエンコードされた文字列を受けて復号し、「マスク済みトークン」を得る必要があります。この後で、トークンのマスクを解除してセッションのトークンと比較します(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def unmask_token(masked_token) # :doc:
  one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
  encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
  xor_byte_strings(one_time_pad, encrypted_csrf_token)
end

#unmask_tokenでトークン復号に必要な暗号マジックを行う前に、マスク済みトークンを必要なパーツ(ワンタイムパッド、暗号化済みトークン自身)に分割しておきます。続いて2つの文字列のXORを取ると、最終的な平文トークンを得られます。

最後に#compare_with_real_token(これはActiveSupport::SecureUtilに依存しています)でトークン同士が一致することを確認します(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def compare_with_real_token(token, session) # :doc:
  ActiveSupport::SecurityUtils.secure_compare(token, real_csrf_token(session))
end

ついにリクエストが承認されました!「通るがよい(you shall pass)」

訳注: これはロード・オブ・ザ・リングのセリフのもじりです(本当はyou shall not pass: ここは通さぬ)。

まとめ

Railsには実にさまざまな要素があるので、Railsでは動いて当たり前のCSRF保護でこんなに頭を使ったのは初めてでした。たまにはこうやって魔法のカーテンの向こう側を覗いて実際の動作を眺めてみるのも楽しいものです。

CSRF保護の実装は、コードベースにおける「責任の分割」のよい例になっていると思います。モジュールを1つ作成し、小さくとも一貫したパブリックなインターフェイスで公開することで、コードベースにほぼまったく影響を与えずに背後の実装に任せることができます。Railsチームが長年にわたってフォームごとのトークンなどの新しい機能をCSRF保護に追加してくれているおかげで、CSRF保護が実際に動く様子をこうして見ることができます。

Railsのコードベースを詳しく調べるたびに、多くのことを学べます。本記事が、Railsのマジックに出会って仕組みを探るときのヒントになればと願っています。

関連記事

Railsアプリの認証システムをセキュアにする4つの方法(翻訳)

Ruby: 8/27発表のRubyGems脆弱性と修正方法のまとめ

そのパッチをRailsに当てるべきかを考える(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ