ちゃんと調査しないと気付かなかったのでメモがてら、誰かが同じ問題に直面したときの助けになれば。
概要
ある日、GETとPOSTで挙動が違うという話が。
コードを見てみたら、コントローラーの同じアクションにGETとPOSTがルーティングされていました。
どう見ても同じ処理。そこに差異は無いので挙動ベースでデバッグしてみることに。
テストコードでは上手く動いていました。でも直接APIを叩いてみると、たしかに違う挙動。
最終的に気付いたのが、問題のあるリクエストではCSRFトークンが渡ってきていないことでした。
アクション内ではsessionが使われており、CSRFトークンが渡されていないことでリクエストは空のsessionに対して処理されていました。これは元々のsessionとは別のものとして扱われ、その結果挙動が変わってしまっていました。
詳細
例としてこのようなコントローラがあるとします。
(POSTの代わりにDELETEにしていますが、挙動は同様です)
class SessionsController < ApplicationController
def destroy
session.delete(:user_id)
end
end
そしてこのようにルーティングされています。
match 'logout', to: 'sessions#destroy', via: [:get, :delete]
前提として session[:user_id]
には既に値が格納されているとします。
このとき、以下の2つはどのように動作するでしょうか?
-
GET /sessions/destroy
へのAPIリクエスト -
DELETE /sessions/destroy
へのAPIリクエスト
コードだけ見ると、明らかにどちらも session[:user_id]
が削除されそうです。疑いようがありません。ただし、 これはCSRFトークンがリクエストに追加されているか否かで挙動が変わります。
2つともCSRFトークンが追加されていない場合、答えはこうです。
-
GET /sessions/destroy
へのAPIリクエストはsession[:user_id]
が削除される -
DELETE /sessions/destroy
へのAPIリクエストsession[:user_id]
が削除されない
なぜか?
ここで ApplicationController
を覗いてみましょう。
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
end
おっと、なにやら protect_from_forgery
というコードが。
※ApplicationControllerに書かれているかは各プロダクトごとに異なります
このprotect_from_forgery with: :null_session
が関係しています。
ドキュメントには以下の記述があります。
CSRF protection is turned on with the protect_from_forgery method. By default protect_from_forgery protects your session with :null_session method, which provides an empty session during request.
CSRF保護は、protect_from_forgeryメソッドでオンになります。デフォルトでは、protect_from_forgeryは、リクエスト中に空のセッションを提供する:null_sessionメソッドでセッションを保護します。
(Google翻訳)
この仕様によって、CSRFトークンによる検証が正しくない場合に session
の中身が空の状態で、かつ別物として処理されます。また、GETではそもそもCSRFトークンの検証がされないため、session
の中身は本来の状態で動作します。
その結果、消したはずの session[:user_id]
が消えていないという挙動になります。
振り返り
なぜちゃんと調査しなければ気付かなかったか?
上記の例ではもしかすると気づけたかもしれません。ですが、 protect_from_forgery
は ApplicationController
側に書かれていたり、それによって普段はあまり意識しなかったりします。これが自分で書いたコードでなければ尚更です。一度経験すればおそらく以後は気付けると思うので、良い経験になりました。
そもそもなぜGETとPOST(GET以外)を同じアクションにルーティングしているのか?
これについては経緯が不明でした。(実際のコードにはコメントがありお気持ちだけは受け止めました)
Railsガイドにもこのような記載があります。
1つのアクションにGETリクエストとPOSTリクエストを両方ルーティングすると、セキュリティに影響する可能性があります。本当に必要な理由がない限り、1つのアクションにすべてのHTTP動詞をルーティングすることは避けてください。
GETとPOSTはRFCで定義された仕様としても別物なので、同じアクションにルーティングするのは避けるべきかなと思います。
さいごに
おそらく今回のものはかなりレアケースかなぁと思います。とはいえどこかの誰かがハマってしまうと悲しいのと、この記事に目を触れた誰かが同様のルーティングをしない、またはルーティングを考える時に注意できる種になればと思います。
お気づきの点があれば気軽にコメントお願いします。些細なことでも歓迎です