【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です(上記は一例です)。
 RailsのCSRF対策protect_from_forgery
この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