Rails API + SPAのCSRF対策例
Leaner Technologies の @corocn です。
本記事では Ruby on Rails の基本的な CSRF 対策の方法である "Synchronizer Token Pattern" を Rails API + SPA の構成でどう実装するかについてお伝えします。
CSRF 対策について解説した記事は多くありますが、最近直面したユースケースに沿った記事がなかったので、改めて整理しておくと誰かの役にたつかなと思ったので書きました。
前提
次のようなシンプルな構成のシステムを考えます。
- ログインして操作するシステム
- Cookie と Session ID でセッション管理をするステートフルな API
- SPA からのリクエストは axios を利用
Rails の強みを生かし、小さく立ち上げたいときにありがちな構成です。Cookie を利用するため、CSRF 対策が必要になります。
Cookie を使う前提のため、SPA と API のドメインの親子関係を維持し、1st party cookie として扱う必要がある部分に注意してください。
セッション管理
認証・認可・セッション管理については本記事では詳しく触れませんが、ペパボさんの記事が今回のシステム構成にも近く、分かりやすいと感じました。
Syncronizer Token Pattern
CSRF 対策にはいくつかありますが、Rails を利用する上での基本的な対策パターンである Syncronizer Token Pattern を利用します。
この手法は OWASP Cheet Sheet でも解説されている古典的な手法です。
大きくは次のような流れになります。
- サーバーサイドで予測不可能なトークンを生成
- 生成したトークンをセッションに紐付け
- 生成したトークンをフロントエンドに送信
- フロントエンドはサーバーへのリクエスト時にトークンを付与
- サーバー側で紐付けられたトークンを検証
Rails ではこの一連の流れを簡略化するためのヘルパーが用意されています。
通常の Rails の場合
通常の Rails の場合、サーバーサイド側で HTML をレンダリングする際に、フォームに authenticity_token という CSRF トークンを自動で生成して出力します。
フォームの送信と共にトークンが送信され、検証が行われます。失敗した場合は、ActionController::InvalidAuthenticityToken
という例外が発生します。
トークンはパラメーターとして送信する params[:authenticity_token]
だけでなく、ヘッダー X-CSRF-Token
でも送信できます。
以下は Rails の検証部分のコードです。参考までに。
# Returns true or false if a request is verified. Checks:
#
# * Is it a GET or HEAD request? GETs should be safe and idempotent
# * Does the form_authenticity_token match the given token value from the params?
# * Does the X-CSRF-Token header match the form_authenticity_token?
def verified_request? # :doc:
!protect_against_forgery? || request.get? || request.head? ||
(valid_request_origin? && any_authenticity_token_valid?)
end
SPA + Rails API の場合
SPA + API の場合はフレームワークで担保してもらえないので、何かしらの方法で SPA 側にトークンを渡す必要があります。
方法は 2 点あります。
- セッションを確認するような API から Cookie 経由で渡す
- CSRF トークン取得用の API から json として返す
どちらもそこまで実装コストは変わらなそうですが、トークンの保存場所を考えなくてよかったり、axios の設定が楽できるので、今回は前者ベースで説明します。
実際に処理の流れを見ていきましょう。
- SPA を開いたタイミングでセッションを確認するための API を叩く
- Cookie
CSRF-TOKEN
が発行されブラウザに記憶される - リクエストの際、axios が Cookie
CSRF-TOKEN
の内容をリクエストヘッダーX-CSRF-Token
にセットして送信する - Rails が
X-CSRF-Token
を検証し処理を続行させる
のような流れになります。
トークン更新タイミング
トークンをどのタイミングで更新すればいいのでしょうか。前に挙げたOWASP Cheet Sheetを見てみましょう。
CSRF tokens should be generated on the server-side. They can be generated once per user session or for each request. Per-request tokens are more secure than per-session tokens as the time range for an attacker to exploit the stolen tokens is minimal.
まとめるとこうです。
- トークンはサーバーサイドで生成すべき
- セッションごとに一度だけ生成すれば基本は OK
- リクエストごとに生成するとより安全
ログインチェック用の API(例えばユーザー情報を取得するような API)で都度更新してしまえば良さそうですね。
Rails API の設定
ここからは実装の説明になります。
まず、Rails API Mode では Cookie がデフォルトで有効になっていないため、明示的に有効にする必要があります。
class Application < Rails::Application
config.load_defaults 6.1
config.api_only = true
...
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
config.middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
end
また、クロスドメインでの通信が発生するため、CORS を設定します。
gem 'rack-cors'
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ['https://example.com']
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
ここまではログイン機能を作った時点で設定されているかもしれません。
次に、ApplicationController で CSRF 対策を有効にし、CSRF-TOKEN
という名前のクッキーで CSRF トークンを返すためのヘルパーメソッドを用意しておきます。ドメイン間の共有設定によって SPA から Cookie を読み取れるようにしています。ドメインの設定は Cookie の全体設定で行っても大丈夫です。
class ApplicationController < ActionController::API
include ActionController::Cookies
include ActionController::RequestForgeryProtection
protect_from_forgery with: :exception
def set_csrf_token
cookies['CSRF-TOKEN'] = {
domain: 'example.com', # 親ドメイン
value: form_authenticity_token
}
end
end
セッションチェック用の API で cookies['CSRF-TOKEN']
を返せるようにします。
class SessionsController < ApplicationController
before_action :authenticate
def show
set_csrf_token
render json: {}, status: :ok
end
end
SPA の設定
axios の設定をします。下記のように設定すると、自動で Cookie に保存された CSRF-TOKEN
をヘッダーに付け替えて送信してくれます。便利です。
import axios from 'axios'
axios.defaults.xsrfCookieName = 'CSRF-TOKEN'
axios.defaults.xsrfHeaderName = 'X-CSRF-Token'
axios.defaults.withCredentials = true
export default axios
リクエスト時は、事前に CSRF-TOKEN
を取得する API を叩いた上で、上記で設定した axios を import して使用します。
import axios from 'front/lib/axios.ts'
// 次のリクエストの結果、SPA側のCookieに CSRF-TOKEN が記憶される
axios.get('https://api.example.com/sessions')
// 以降は、 axios 使用してリクエストすると自動的に X-CSRF-Token ヘッダにトークンつけてリクエストしてくれる
axios.post('https://api.example.com/xxx', { foo: bar })
axios.post('https://api.example.com/yyy', { foo: bar })
axios.post('https://api.example.com/zzz', { foo: bar })
これで対策は完了です。
まとめ
Rails API モードはステートレスを前提としていますが、ステートフルな作りにしたい場合も度々発生します。Cookie を利用してステートフルな作りにする場合、CSRF 対策が必要になります。対策方法は色々とありますが、それぞれの手法のメリデメを理解してユースケースに合うものを選択していきたいですね。
宣伝
Leaner Technologies では SPA のセキュリティについて議論したいエンジニアも募集しています!
Discussion
いい記事ありがとうございます。
めちゃめちゃ申し訳ない。いま気づきました!修正しておきます〜