RailsにおけるCSRF対策をできるだけ分かりやすく説明する
はじめに
Webサービスを構築する上で、セキュリティ対策はとても大切なことだと思います。
Railsを使っているとよしなにセキュリティ対策をしてくれていることがほとんどなのですが、
使い方次第では正しく機能していないということもあると思います。
この記事ではクロスサイトリクエストフォージュリ(以下CSRFと呼びます)について説明していきます。
・バージョン
Ruby:2.6
Rails:6.1
gemはvendor/bundleにインストールしています。記事に書かれているパスを参照する時にはご自身のgemのインストール先に読み替えてください。
CSRF攻撃とは
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_forgery
がtrue
であれば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_session
、with: 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