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行を有効にします(訳注2021/11/26: Rails 5.2以降はコントローラに書かなくてもデフォルトでconfig.action_controller.default_protect_from_forgery = true
が有効になります: Railsガイド)。
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_token
にsession
を渡す理由がおわかりでしょうか。このメソッドは実際には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のマジックに出会って仕組みを探るときのヒントになればと願っています。
概要
原著者の許諾を得て翻訳・公開いたします。