【Rails】CSRF対策メソッドprotect_from_forgeryについて調べてみた
こんにちは。Webエンジニア転職活動中のyoshinoと申します。
ポートフォリオとして、Rails7でスケジュール通知アプリ「トリコミ」を開発しています(下記/もしよければ使ってみてください!)。
開発中に、CSRFと呼ばれる脆弱性に対応するRailsのメソッドprotect_from_forgery
について興味を持ったので、その挙動を詳しく調べてみました。
CSRF(クロスサイトリクエストフォージェリ)とは?
Webセキュリティといえばこの本、「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践」、通称“徳丸本”の4.5章に詳しく書いてあります。
安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ):IPA 独立行政法人 情報処理推進機構より
一度クッキーを覚えたブラウザは、その後同じサイトにリクエストを送信する際には覚えたクッキー値を送信するという性質があります。
これを悪用し、
- 利用者がWebアプリケーションにログインする。利用者のブラウザのクッキーにセッション情報が保存される。
- 利用者がログイン状態のまま罠のサイトにアクセスする。
- 罠のサイトに仕掛けられた罠により、利用者が気づかないうちに、利用者のブラウザからWebアプリケーションに対して「重要な処理」を実行するリクエストが送られる。※「重要な処理」……利用者のアカウントでの物品購入、アカウントの退会処理など。
- 3.のリクエストには1.で保存されたセッション情報を含むクッキーが含まれているので、Webアプリケーション側では正常なリクエストとして処理される。
という経過を辿る攻撃手法がCSRFです(上記は一例です)。
protect_from_forgery
RailsのCSRF対策このCSRF脆弱性に対する一般的な対策手法として、第三者には推測されにくいトークンをセッションとフォームのhiddenパラメータなどに埋め込んで、それらを「重要な処理」を実行する前にWebアプリケーション側で検証するという手法があります。
Railsにもこの対策が組み込まれており、protect_from_forgery
というメソッドがそれを担当しています。protect_from_forgery
はRails5.2系よりデフォルトで有効化されているので、自分でコントローラに追記しなくても勝手に働いてくれています。
詳しい仕組みはここでは割愛しますが、下記の記事がわかりやすかったです。
Railsアプリで実験してみる
下準備
では、そのprotect_from_forgery
の働きを実際にRailsアプリで確認してみましょう。
デモ用のアプリを生成します。
$ rails new csrf_app
アプリの処理を一時的に止めて挙動を確認したいので、デバッグ用のgemをインストールします。
# 下記を追記
gem 'pry-rails'
$ bundle install
scaffold
コマンドでモデル、ビュー、コントローラ、ルーティングをまとめて作ります。
$ rails g scaffold post title:string body:string
データベースを作成し、サーバーを起動します。
$ rails db:migrate
$ rails s
http://localhost:3000
http://localhost:3000/posts
デモアプリの雛形ができたので、protect_from_forgery
の挙動を確認してみます。今回は、Postを新規作成するPOSTリクエスト(posts#create
アクション)が実行される前に、protect_from_forgery
によってトークンがどのように検証されているのかを見てみます。
処理を一時的に止めるために、app/controllers/posts_controllers.rb
に次のように追記します。
class PostsController < ApplicationController
# 下記を追記
# createアクションの前に処理を一度止めてトークンを確認してみる
# before_actionチェーンの先頭で実行する
prepend_before_action :confirm_authenticity_token, only: :create
# 以下略
private
# 下記を追記
def confirm_authenticity_token
binding.pry
end
# 以下略
end
prepend_before_action
は、before_action
で登録されるbefore
のcallback
チェーンの先頭にメソッドを追加するメソッドです。
初期状態でbefore
のcallback
チェーンにはprotect_from_forgery
が登録されているので、それよりも先にconfirm_authenticity_token
を実行できるようにしました。
トークンの確認
さて、/posts/new
にアクセスして、Railsが埋め込んでいるトークンを確認してみましょう。
まず、<head>
タグ内にcsrf-token
が埋め込まれています。
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="guI_DuaCPelKwuohWtnt1rVRtb8qNgh7sRdjMhop5YbmbwSoNXLoycxdE4RYIlY4hWNxNw-dMmj6tRhIBpOcHQ">
そして、<body>
の<form>
タグ内にも、hiddenパラメータとしてauthenticity_token
が埋め込まれています。
<input type="hidden" name="authenticity_token" value="jjdmoumKu8JwN71kMhfjw5Fsz7HP--RXKCMlHk8IOa9EUdQS3kNykoKbNiEIojgYGK8ynNX2DT5a95raXiSoTg" autocomplete="off">
Cookieを見てみましょう。_csrf_app_session
という値をSetするようにという指示が、/posts/new
のレスポンスヘッダにありました。
この後、フォームに内容を入力してsubmit
ボタンを押すと、/posts
(posts#create
)にPOSTリクエストが送られます。
このとき、この3つのトークンも一緒にアプリケーションに送られます。そして、アプリケーションはリクエストを実行する前にprotect_from_forgery
を実行し、複雑な復号化の過程を経て、Cookieの_csrf_app_session
が、リクエストヘッダーのHTTP_X_CSRF_TOKEN
かパラメータのauthenticity_token
のどちらかと一致すれば、リクエストが実行される仕組みになっています。[参照]
通常のリクエスト
まずは普通にPostを作成してみます。
From: /csrf_app/app/controllers/posts_controller.rb:70 PostsController#confirm_authenticity_token:
68: def confirm_authenticity_token
=> 69: binding.pry
70: end
[1] pry(#<PostsController>)> params[:authenticity_token]
=> "8YZ_KZMVQZIohbLXxm4DzpF7VjUaDZdTAZJ7-ew6DUM74M2ZpNyIwtopOZL829gVGLirGAAAfjpzRsQ9_Racog"
[2] pry(#<PostsController>)> request.headers[:HTTP_X_CSRF_TOKEN]
=> "Bopk7uQk6iPri2S1gIfqZpIOMm3MwEgIk6VlS5tznYNiB19IN9Q_A20UnRCCfFGIojz25elrchvYBx4xh8nkGA"
[3] pry(#<PostsController>)> cookies[:_csrf_app_session]
=> "Ao3sZ1wj4ewR8IxsPMWdMjJb6Nr6mq0M8F04Kpwa/VWxd5tm7C5CIVEQ7qIbnxSvT0pkco/aETsR1URp5Q/Ui+P/w8BYspjNxcz/Cp3t42aUSQxygmoNNZRhO0mTbbb8aX47fM2R8fkZBa6ApH2izzb6cR7E42yD4rbdIBfIdLiBg3rJk95hu5ogzEVzxzF/vSDM4J9uAzEpppzK7FhEUf79CrfYsuiBSEssH+2vo7sGrk3sJqJdlLYQfb/uyB/8Ae0txrxcKtDiNxE54bbgoQj91JCfIjq4XA==--6yfA16+R30GjUcoV--+qjN3Lp0wJ2yEVQMeAa+oQ=="
[4] pry(#<PostsController>)> exit
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/posts_controller.rb:34:in `block in create'
Post Create (1.5ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["title", "test"], ["body", "test"], ["created_at", "2022-11-24 04:26:44.627299"], ["updated_at", "2022-11-24 04:26:44.627299"]]
↳ app/controllers/posts_controller.rb:34:in `block in create'
TRANSACTION (0.6ms) commit transaction
↳ app/controllers/posts_controller.rb:34:in `block in create'
Redirected to http://localhost:3000/posts/1
Cookieの_csrf_app_session
、リクエストヘッダーのHTTP_X_CSRF_TOKEN
、パラメータのauthenticity_token
が揃った状態ではPOSTリクエストが通りました。
トークンのないリクエスト
次に、リクエストヘッダーのHTTP_X_CSRF_TOKEN
とパラメータのauthenticity_token
の両方を削除してPOSTリクエストを送ってみます。
From: /csrf_app/app/controllers/posts_controller.rb:70 PostsController#confirm_authenticity_token:
68: def confirm_authenticity_token
=> 69: binding.pry
70: end
[1] pry(#<PostsController>)> params[:authenticity_token] = ''
=> ""
[2] pry(#<PostsController>)> request.headers[:HTTP_X_CSRF_TOKEN] = ''
=> ""
[3] pry(#<PostsController>)> exit
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity in 7522ms (ActiveRecord: 0.0ms | Allocations: 12806)
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):
# 以下略
Can't verify CSRF token authenticity.
というエラーが出て、リクエストに対するレスポンスは422 Unprocessable
となり、ブラウザはposts/new
に戻されてしまいました。
このように、正しいトークンを持たないPOSTリクエストはRailsアプリで実行できないようになっているわけです。
protect_from_forgery
の無効化
次のように書くと、protect_from_forgery
を無効にすることができます。
class PostsController < ApplicationController
# 下記を追記
# createアクションでprotect_from_forgeryを無効にする
protect_from_forgery except: :create
# 以下略
end
protect_from_forgery
を無効にした状態で、もう一度リクエストヘッダーのHTTP_X_CSRF_TOKEN
とパラメータのauthenticity_token
の両方を削除したPOSTリクエストを送ってみましょう。
From:
/csrf_app/app/controllers/posts_controller.rb:70 PostsController#confirm_authenticity_token:
68: def confirm_authenticity_token
=> 69: binding.pry
70: end
[1] pry(#<PostsController>)> request.headers[:HTTP_X_CSRF_TOKEN] = ''
=> ""
[2] pry(#<PostsController>)> params[:authenticity_token] = ''
=> ""
[3] pry(#<PostsController>)> exit
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/posts_controller.rb:34:in `block in create'
Post Create (1.1ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["title", "test without token"], ["body", "test without token"], ["created_at", "2022-11-24 04:48:49.860984"], ["updated_at", "2022-11-24 04:48:49.860984"]]
↳ app/controllers/posts_controller.rb:34:in `block in create'
TRANSACTION (0.5ms) commit transaction
↳ app/controllers/posts_controller.rb:34:in `block in create'
Redirected to http://localhost:3000/posts/2
Completed 302 Found in 10458ms (ActiveRecord: 2.1ms | Allocations: 17118)
protect_from_forgery
を無効にしているので、トークンが揃っていない状態でもPOSTリクエストが通ってしまいました。
感想
このメソッドについて調べ始めたきっかけは、個人開発中にCan't verify CSRF token authenticity.
のエラーが頻発し、その対処法について検索すると、安直にprotect_from_forgery
を無効にしている記事が多かったからです。
でも、それって本当に大丈夫? と疑問に思って調べてみると、とても奥深く、まだまだRailsのことを知らなかったと思い知らされました。
セキュリティの正しい知識を身につけて、これからも開発を楽しみたいと思います!
Discussion