🛤️

RailsにおけるCSRF対策をできるだけ分かりやすく説明する

2022/11/21に公開

はじめに

Webサービスを構築する上で、セキュリティ対策はとても大切なことだと思います。
Railsを使っているとよしなにセキュリティ対策をしてくれていることがほとんどなのですが、
使い方次第では正しく機能していないということもあると思います。
この記事ではクロスサイトリクエストフォージュリ(以下CSRFと呼びます)について説明していきます。

・バージョン
Ruby:2.6
Rails:6.1

gemはvendor/bundleにインストールしています。記事に書かれているパスを参照する時にはご自身のgemのインストール先に読み替えてください。

CSRF攻撃とは

Wikiがなかなか分かりやすいので、一度目を通しておくと理解が深まると思います。
https://ja.wikipedia.org/wiki/クロスサイトリクエストフォージェリ

ざっくりと説明すると、
「本人が気づかないところで、悪意のあるユーザーによって用意された罠により(攻撃により)、予期しないリクエストを送ってしまい、何かしらの被害を被ってしまうこと」 です。
被害例:商品購入、送金、サービスからの退会、危険な書き込み(殺害予告など)

過去の有名な事件としてはぼくはまちちゃん事件大阪誤認逮捕事件などがあります。

CSRF対策をする

実際にサーバーがリクエストを受け取ったときに、そのリクエストが本当にユーザーが意図して送ったきたものかを判別する必要があります。
Railsでは、意図されたリクエストかを判別するためのprotect_from_forgeryというメソッドがあります。このメソッドはユーザーの意図していないリクエストかどうかを検証し、意図していないと判断した場合に、よしなに対応をしてくれる、というものです。「よしなに」についてはもう少し詳しく後述します。

protect_from_forgeryを理解するための事前知識

Railsのprotect_from_forgeryを理解するために必要な事前知識から確認していきます。
RailsでCSRF対策をする上で重要な役目を果たすのがセッションauthenticity_token(トークン) です。

RailsではデフォルトでHTMLの<head>タグとform_withで作成された<form>タグの中にauthenticity_tokenという名前の要素が追加されます。<head>にはmetaタグとして追加されます。formにはtype=hiddenとなっており、フォームsubmit時に他のパラメータと一緒に送られます。

以下は簡易なTODOの作成時のform dataです。

authenticity_tokenというキーに文字列が設定され送信されます。
サーバーでは送られてきたauthenticity_tokenが妥当かを検証します。

この検証の際に、セッションが使われます。
セッションには_csrf_tokenというキーが埋まっており、ユーザーごとにトークンを管理しています。
<head>タグ、<form>タグのトークンの値を生成するときにはsession[_csrf_token]に埋まっている値をもとに生成しています。

セッションに埋まっているトークンの値とHTML(headタグ、formタグ)に埋まっているトークンの値は同じ値です。
これによりブラウザから送られてきたトークンとセッションに埋まっているトークンが同じであれば、意図したリクエストと判断し、異なる値であれば、第三者が用意した悪意のある罠かもしれない、と判断します。

※実際のHTMLの<head>タグのトークン値と<form>タグ内のトークン値を見比べると違う値に見えますが、暗号化されているためそう見えるだけであり、復号すると実際には同じ値です。

session[_csrf_token]はいつ埋まっているか

session[_csrf_token]は一度設定されるとセッションが切れるまで変わりません。
設定されるタイミングは初めてトークンを埋めようとしたタイミングです。
sample_csrf/vendor/bundle/ruby/2.6.0/gems/actionpack-6.1.7/lib/action_controller/metal/request_forgery_protection.rb

def real_csrf_token(session) # :doc:
  session[:_csrf_token] ||= generate_csrf_token
  decode_csrf_token(session[:_csrf_token])
end

上記メソッドはトークンの値を設定するときに実行されるメソッドです。
||=となっており、一度値が埋まっていれば右辺は実行されません。

基本的にはapplication.html.erb(sample_csrf/app/views/layouts/application.html.erb)
の下記が最初に呼ばれるタイミングで埋まっているかと思います。

<%= csrf_meta_tags %>

protect_from_forgeryを使いこなす

これまで説明してきたように、デフォルトでCSRF対策はなんか良い感じに対応できてそうだな、というのを感じられたかと思います。
では<form>タグ内のトークンの値がセッションで保持している値と一致しなかった場合にどうなるでしょうか?

RailsではデフォルトでActionController::InvalidAuthenticityTokenエラーがraiseされます。

Rails 5.2からdefault_protect_from_forgeryというオプションが追加され、ActionController::Baseにてdefault_protect_from_forgery = trueが設定されています。
コミットはこちら

actionpack/lib/action_controller/railtie.rb

    initializer "action_controller.request_forgery_protection" do |app|
      ActiveSupport.on_load(:action_controller_base) do
        if app.config.action_controller.default_protect_from_forgery
          protect_from_forgery with: :exception
        end
      end
    end

default_protect_from_forgerytrueであればprotect_from_forgeryメソッドの引数としてwith: :exceptionを渡しています。

protect_from_forgeryメソッドは何をやっているか見てみます。
sample_csrf/vendor/bundle/ruby/2.6.0/gems/actionpack-6.1.7/lib/action_controller/metal/request_forgery_protection.rb

      def protect_from_forgery(options = {})
        options = options.reverse_merge(prepend: false)

        self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
        self.request_forgery_protection_token ||= :authenticity_token
        before_action :verify_authenticity_token, options
        append_after_action :verify_same_origin_request
      end

重要なのはbefore_action :verify_authenticity_token, optionsでトークンが妥当かをチェックするメソッドをbefore_actionで定義しているところです。

これにより、ApplicationControllerなどにprotect_from_forgeryの記載をせずにこの挙動が実現されています。(少し前はRails newをするとApplicationControllerにprotect_from_forgeryの記載があったと思います)

他にはwith: :null_sessionwith: reset_sessionの2つがあります。

with: :null_session

こちらの設定にした場合、トークンが不正な場合にセッションを{}(空ハッシュ)としてそのリクエストを実行します。
なのでログインしたユーザーの情報などをセッションから取り出して処理を実行する場合は
セッションが空となっているので正しく処理が実行されないこととなるかと思います。

with: :reset_session

こちらの設定はセッションをリセットします。ログイン情報などが消えるため、ユーザーには再度ログインを促すこととなるかと思います。

設定はどうすべきか?

Railsでビューを作って返す場合はデフォルトの設定で問題ないと思います。
SPAなどでフロントはReactでRailsサーバーからは一部のHTMLを返す(body内にidを持ったdivタグなどをを仕込んでscriptタグでjsを読み込み、JSXをマウントする)みたいなことをする場合には<head>タグに仕込んでいるauthenticity_tokenの値をajaxリクエスト時に送るようにするのがよくあるパターンかと思います。

POSRリクエスト時にX-CSRF-Tokenヘッダを追加し、そこにトークンの値を埋めて送ると
Railsサーバーはその値を使ってprotect_from_forgeryを実行してくれます。

終わりに

長くなってしまいましたが、protect_from_forgeryを実装しているRequestForgeryProtectionモジュールはストラテジパターンを使っていて、Rubyコードの学習にもとても良い教材でした。
時間がある時にコードを覗いて見ると、新しい気づきがあるかもしれません。

間違いや分かりにくいところがあればご指摘いただけると幸いです。

Discussion