🔖

【Rails】CSRF対策メソッドprotect_from_forgeryについて調べてみた

2022/11/24に公開

こんにちは。Webエンジニア転職活動中のyoshinoと申します。

ポートフォリオとして、Rails7でスケジュール通知アプリ「トリコミ」を開発しています(下記/もしよければ使ってみてください!)。

https://torikomi.fly.dev/

開発中に、CSRFと呼ばれる脆弱性に対応するRailsのメソッドprotect_from_forgeryについて興味を持ったので、その挙動を詳しく調べてみました。

CSRF(クロスサイトリクエストフォージェリ)とは?

Webセキュリティといえばこの本、「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践」、通称“徳丸本”の4.5章に詳しく書いてあります。

https://www.amazon.co.jp/dp/4797393165/


安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ):IPA 独立行政法人 情報処理推進機構より

一度クッキーを覚えたブラウザは、その後同じサイトにリクエストを送信する際には覚えたクッキー値を送信するという性質があります。

これを悪用し、

  1. 利用者がWebアプリケーションにログインする。利用者のブラウザのクッキーにセッション情報が保存される。
  2. 利用者がログイン状態のまま罠のサイトにアクセスする。
  3. 罠のサイトに仕掛けられた罠により、利用者が気づかないうちに、利用者のブラウザからWebアプリケーションに対して「重要な処理」を実行するリクエストが送られる。※「重要な処理」……利用者のアカウントでの物品購入、アカウントの退会処理など。
  4. 3.のリクエストには1.で保存されたセッション情報を含むクッキーが含まれているので、Webアプリケーション側では正常なリクエストとして処理される。

という経過を辿る攻撃手法がCSRFです(上記は一例です)。

RailsのCSRF対策protect_from_forgery

このCSRF脆弱性に対する一般的な対策手法として、第三者には推測されにくいトークンをセッションとフォームのhiddenパラメータなどに埋め込んで、それらを「重要な処理」を実行する前にWebアプリケーション側で検証するという手法があります。

Railsにもこの対策が組み込まれており、protect_from_forgeryというメソッドがそれを担当しています。protect_from_forgeryはRails5.2系よりデフォルトで有効化されているので、自分でコントローラに追記しなくても勝手に働いてくれています。

詳しい仕組みはここでは割愛しますが、下記の記事がわかりやすかったです。

https://techracho.bpsinc.jp/hachi8833/2021_11_26/46891

https://railsguides.jp/security.html#クロスサイトリクエストフォージェリ(csrf)

Railsアプリで実験してみる

下準備

では、そのprotect_from_forgeryの働きを実際にRailsアプリで確認してみましょう。

デモ用のアプリを生成します。

$ rails new csrf_app

アプリの処理を一時的に止めて挙動を確認したいので、デバッグ用のgemをインストールします。

Gemfile
# 下記を追記
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に次のように追記します。

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で登録されるbeforecallbackチェーンの先頭にメソッドを追加するメソッドです。

https://techracho.bpsinc.jp/baba/2013_08_19/12657

初期状態でbeforecallbackチェーンには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ボタンを押すと、/postsposts#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を無効にすることができます。

app/controllers/posts_controllers.rb
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