【Rails】 CSRF 保護で使用される protect_from_forgery の with オプションについて調べた
Rails には CSRF 保護を行うためのメソッド protect_from_forgery が用意されています。
ApplicationController に記載することで view の head タグや form タグに仕込まれているトークンと、セッションにあるトークンを照合する処理が動き CSRF 保護を実現します。
このトークンの照合に失敗した場合の処理を指定するのが :with オプションです。
例えば、以下のように :exception を指定するとトークンの照合に失敗したときに例外を発生させます。
protect_from_forgery with: :exception
最近 protect_from_forgery まわりの実装を触る機会があったので、:with オプションについて調べてみました。
Rails のバージョンは 7.1.3 を前提としています。
:with オプションに指定できる値
メソッド protect_from_forgery に書かれているコメントとそれを機械翻訳したものが以下です。
:with- 検証されていないリクエストを処理するメソッドを設定します。default_protect_from_forgeryが true の場合、Railsは:exception付きでprotect_from_forgeryを呼び出すことに注意してください。組み込みの検証されていないリクエスト処理方法は以下の通り:
:exception-ActionController::InvalidAuthenticityToken例外を発生させます。:reset_session- セッションをリセットします。:null_session- リクエスト時に空のセッションを提供しますが、完全にはリセットしません。withオプションが指定されていない場合にデフォルトで使用されます。検証されていないリクエストを処理するためのカスタム戦略クラスを実装することもできる:
class CustomStrategy def initialize(controller) @controller = controller end def handle_unverified_request # Custom behavior for unverfied request end end class ApplicationController < ActionController::Base protect_from_forgery with: CustomStrategy end
最初に例で上げた :exception 以外にも :reset_session :null_session カスタムクラス の3通りの指定が可能なようです。
:reset_session を指定すると、名前の通りセッションがリセットされた上でリクエストが継続されるようです。
:null_session はセッションのリセットはせず、そのリクエストにおいてはセッションを空の状態で継続するようで、 :with オプションを指定しない場合のデフォルトはこれのようです。
カスタムクラス を指定する場合は、トークン照合失敗時の処理を実装した handle_unverified_request メソッドをもつクラスを指定するようです。
:null_session がデフォルトな理由
個人的に驚きだったのがデフォルトが :null_session ということでした。
なぜなら、Rails 5.2 から CSRF 対策として protect_from_forgery が初期状態でオンになる対応が入りましたが、それは with: :exception で指定されているからです。つまり現状 rails new で作成した Rails アプリは初めから protect_from_forgery with: :exception で CSRF 保護が働くようになっています。
この Rails の初期状態に反して :with オプションのデフォルトが :null_session なのはなにか理由があるのでしょうか。
過去の commit をたどってみると、:with オプションが実装される直前では protect_from_forgery の照合失敗時の処理はセッションをリセットする実装のみだったようです。
そんな中、攻撃等でセッションがリセットされてしまうと正しいリクエストがその後行われたときに毎回再認証が必要になりそれもまた脆弱性ではないかという指摘が Issue #5300 でされたようです。そこで合わせて :exception や :null_session の機能も要求されていました。ちなみにこの時点では :with のデフォルトに :exception が希望されています。
これにより、PR #5326 で :exception を追加する対応がされ、PR #7616 で :null_session の機能が追加されたようです。
しかしこの時点でデフォルトは :exception ではなく :null_session となりました。
明確な説明は見つけられなかったのですが、PR #5326 で 基本的にAPI サーバではセッションを使用しないため、例外を上げずセッションをクリアするのがデフォルトであるべきというコメントがあり、これに倣ったのだろうと思いました。確かにセッションを使用しない環境では CSRF 保護は必要なくトークンはリクエストに含まれないため :exception をデフォルトにしてしまうと例外が上がってしまいます。その場合、互換性の維持のために設定を無効化する手間がかかります。
セッションをリセットするデメリットと、セッションを使用しない API サーバのような用途への互換性を考慮して、 :null_session をデフォルトとしたのではないかと想像できます。
それでも Rails の初期状態は protect_from_forgery with: :exception になる
Rails 5.2 から rails new した際の初期状態では protect_from_forgery with: :exception になる対応が入っています。
:exception や :null_session が機能として加わった PR は2012年のもので、もうすぐ 4.0 になるくらいの時期なので、ここからそこそこ経ってから実装されたようです。
発端となったのは Issue #29193 で、 PR #29742 で対応されているようです。ただ Issue に経緯などの詳しい記載はなさそうでした。
PR のコメントを見ていくと、やはり :with オプションのデフォルトが :null_session なので分かりづらい。対応するなら :with オプションのデフォルトも :exception に統一すべき。という声があがっています。
対して互換性の問題が指摘されており、やるなら一旦非推奨にして段階的にだけどそこまですべきかというコメントもあります。
結果的には :exception に統一する PR #39608 は作られたものの merge されることなく close されています。互換性の問題とのせめぎ合いで統一しない方向に軍配が上がったように見えます。
統一されておらず分かりづらいことについては、注意書きのコメントを追加する PR #49374 で最近になって対応されています。
そもそもの Rails の初期状態で :exception を指定したかった経緯はわかりませんでした。時期的に Rails 5 で CSRF 保護を最初から含まない API モード が実装されたことも関係あるのかなと思いましたが、関連するコメントは見当たりませんでした。
カスタムクラス が実装された経緯
ついでに :with オプションにカスタムクラスを指定できる機能についても、どういう経緯で実装されたのか commit をたどってみました。
PR #43444 で実装されたようです。
コメントを一部抜粋すると以下の通りです。
Currently, Rails supports custom CSRF strategies accidently. In the #protection_method_class it is possible to pass a Class or a Symbol object and the .to_s.classify call behaves similarly in both cases.
現在、RailsはカスタムCSRFストラテジーを偶然サポートしています。protection_method_classではClassオブジェクトまたはSymbolオブジェクトを渡すことができ、.to_s.classifyコールはどちらの場合でも同じように動作します。
偶然 Rails 内部の実装が汎用的なクラスに対応してるから外から渡せるようにもしよう、といった感じでしょうか。たしかにこれだけ多くの利用者がいるオープンソースなので、実装の手間がそこまでかからないのであれば、汎用性は高いに越したことはないのかもしれません。その分互換性の維持も求められる気はしますが。
処理を終了させる際に exception の rescue からではカバーしきれない場合などで使用されることがあるかもしれません。
おわりに
CSRF 保護で使用されるメソッド protect_from_forgery のオプション引数 :with について GitHub のチケットを遡り調べてみました。
1メソッドの1オプションに関してのみですが、長く使われており変遷を辿っているだけあっていろいろ深堀りできるものだなと思いました。
Discussion