🗿

InvalidAuthenticityToken探検隊

2024/12/18に公開

はじめに

本記事ではRailsのCSRF対策についてソースコードを追って深掘りしていますが、あくまでコードを読んだ個人の解釈であり、セキュリティ対策の最終的な判断と実装は読者自身の責任となることご理解ください。もし誤った内容を言及している箇所などありましたら優しくコメントをしていただけると幸いです。

目的

先日同じチームのメンバーがInvalidAuthenticityToken関連のタスクを対応していました。自分も過去に対応したことあるはず?ですが正直そんなにこの例外を理解しているわけではなく「あぁCSRFのやつね」という感じの理解なので、本記事では改めて①CSRFがどのような攻撃で②Railsがそれに対してどのように対策を講じていて、③どのようなパターンでInvalidAuthenticityTokenを発生させているのかをソースコードを追って深堀りしていきます。

CSRFとは

CSRFとはCross Site Request Forgeryの略でForgeryとは「偽造、贋造」という意味で、言葉の意味するところだけを汲み取ると異なるホストのWebサイトへ偽造されたリクエストを送る攻撃であると言えそうです。

具体的な内容を確認するにはIPAやCloudflareなどのドキュメントが参考になります。
https://www.ipa.go.jp/security/vuln/websecurity/csrf.html
https://www.cloudflare.com/ja-jp/learning/security/threats/cross-site-request-forgery/

ターゲットのサイトのセッションを悪用し意図しないデータの操作を行わせるというのがこの攻撃の特徴です。直接的にデータを抜き取るということはなさそうですが、管理者の認証情報を変更すれば重大な被害が出る可能性があります。

実際にサンプルサイトもありCSRFを体験することもできるのでご興味のある方はぜひ。
https://qiita.com/sagami1991/items/23f72ab4e6552221188a

RailsにおけるCSRF

ではRuby on RailsではどのようにしてCSRFを防いでいるのでしょうか。

Guideに記載がありますがデフォルトでセキュリティトークンが導入されておりこれによりCSRFを防いでいるようです。
https://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf
設定を有効にしアプリケーションコントローラに以下のコードを追加することでRailsで生成する全てのフォームとAjaxリクエストにトークンが付与され、それをサーバー側で検証することでCSRFを防いでいます。

protect_from_forgery with: :exception

このセキュリティトークンはサーバー側でどのように検証され、そして上記で述べたInvalidAuthenticityTokenとどのような関係があるのでしょうか。
以下ではInvalidAuthenticityTokenの発生条件とセキュリティトークンの検証の仕組みとをソースコードを追って深掘りしてみます。

InvalidAuthenticityTokenエラーとは

CSRF対策は以下のファイルで行われているようです。
https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb

https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L41-L43

上記コメントにあるようにActionController::Base のサブクラスは、デフォルトで :exception 戦略で保護されており、検証されていないリクエストに対して ActionController::InvalidAuthenticityToken エラーを発生させているようです。※戦略は protect_from_forgerywith: オプションの値の部分になります。

ではどのようにリクエストを検証しているのでしょうか。

InvalidAuthenticityTokenエラーが発生するパターン

例外発生箇所

ActionController::InvalidAuthenticityTokenは以下で raise されています。
https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L311

この handle_unverified_requestverify_authenticity_token で呼び出されており、戦略が :exception 且つ verified_request? が false の場合に例外が発生するようになっています。
https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L389

文字通りリクエストの検証は verified_request? で行っているようですが、メソッドの中身を見ると実態は valid_request_origin? && any_authenticity_token_valid? のようです。
https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L461

valid_request_origin?

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L625

こちらはリクエストが同じオリジンからのものかどうかを確認しています。デフォルトは true を返し、リクエストのオリジンが文字列で null の場合、若しくは base_url と同一の場合は true を、それ以外は false を返します。

any_authenticity_token_valid?

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L466-L471

恐らくリクエスト検証の本丸はここです。
リクエストに含まれるトークン( request_authenticity_tokens )を valid_authenticity_token? で検証して一つでも true を返すとメソッド自体が true を返すようです。

request_authenticity_tokens

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L474

こちらはリクエストに含まれるトークンを配列に格納しています。要素はパラメーターに含まれるトークンとヘッダーに含まれるトークンの2つになります。

inputタグの authenticity_token の値がパラメータートークンとして、
リクエストヘッダーの X-CSRF-TOKEN の値がヘッダートークンとして格納されます。

valid_authenticity_token?(session, token)

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L500

こちらはリクエストに含まれるトークンを検証するメソッドになります。コメントにある通り、クライアントのマスクされたトークンがセッショントークンと一致するかを確認します。

引数で渡した token を Base64 でデコードしたトークンと、セッションが保持するトークンとを安全に比較しているようです。* ActiveSupport::SecurityUtils.fixed_length_secure_compare はここでは言及しないです。機密情報の文字列を安全に比較し同一だと true を返すメソッドだと思ってください。

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L545

ここでいう real_csrf_token(session) がセッションが保持するトークンになります。

real_csrf_token(_session = nil)

https://github.com/rails/rails/blob/a725732b3dee53a102d62cb193c02dc886bbb7ea/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L566

こちらがセッション側のトークンを取得するメソッドになります。ちょっと難解💦
Hash#fetch は与えられたキーが要素にない場合にブロックの内部を評価します。(参照
この場合、 request.env (=リクエストを受け付けて処理する際のRackの環境設定)というハッシュオブジェクトにCSRF_TOKENという環境変数で設定した文字列のキーがあればその値を返し、
もし無ければセッション情報からCSRFトークンを取得するようになってます。セッション情報にトークンが存在しない場合新たにトークンを生成して返しているのですがこれは何故だろう 🤔 (real_csrf_tokenと言えるのだろうか。。。)
取得したトークンはデコードしてクライアント側のトークンと比較する処理に渡されます。

おわりに

ちょっと雑にソースコードを追ってみましたが、Railsセキュリティガイドにある通りクライアント側のトークンをサーバー側で検証する流れを確認することができました。また、InvalidAuthenticityTokenverified_request? メソッドの判定に関わりがあり、フォームに付与されたトークン若しくはリクエストヘッダーに含まれるトークンと、Rackの環境設定やセッションが保持するトークンを比較し一致しない場合に例外が発生するようになっていました。(関係ないですがこのファイルのメソッドの命名が素晴らしくてコード追いやすかったです。)

ここに記載のあるのは :exception 戦略の際に呼び出される処理のほんの一部ですが、他の戦略やクライアント側へのトークンの生成・付与の処理も追って理解を深められればと思いました。

Discussion