🛡

Rails API + SPAのCSRF対策例

2021/09/30に公開
2

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 として扱う必要がある部分に注意してください。

セッション管理

認証・認可・セッション管理については本記事では詳しく触れませんが、ペパボさんの記事が今回のシステム構成にも近く、分かりやすいと感じました。

https://tech.pepabo.com/2020/09/23/session-management-for-web-apps-using-spa-ssr-api/

Syncronizer Token Pattern

CSRF 対策にはいくつかありますが、Rails を利用する上での基本的な対策パターンである Syncronizer Token Pattern を利用します。

この手法は OWASP Cheet Sheet でも解説されている古典的な手法です。

https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern

大きくは次のような流れになります。

  1. サーバーサイドで予測不可能なトークンを生成
  2. 生成したトークンをセッションに紐付け
  3. 生成したトークンをフロントエンドに送信
  4. フロントエンドはサーバーへのリクエスト時にトークンを付与
  5. サーバー側で紐付けられたトークンを検証

Rails ではこの一連の流れを簡略化するためのヘルパーが用意されています。

https://techracho.bpsinc.jp/hachi8833/2017_10_23/46891

通常の 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

https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L319

SPA + Rails API の場合

SPA + API の場合はフレームワークで担保してもらえないので、何かしらの方法で SPA 側にトークンを渡す必要があります。

方法は 2 点あります。

  • セッションを確認するような API から Cookie 経由で渡す
  • CSRF トークン取得用の API から json として返す

どちらもそこまで実装コストは変わらなそうですが、トークンの保存場所を考えなくてよかったり、axios の設定が楽できるので、今回は前者ベースで説明します。

実際に処理の流れを見ていきましょう。

  1. SPA を開いたタイミングでセッションを確認するための API を叩く
  2. Cookie CSRF-TOKEN が発行されブラウザに記憶される
  3. リクエストの際、axios が Cookie CSRF-TOKEN の内容をリクエストヘッダー X-CSRF-Token にセットして送信する
  4. 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 がデフォルトで有効になっていないため、明示的に有効にする必要があります。

config/application.rb
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 を設定します。

Gemfile
gem 'rack-cors'
rails-server/config/cors.rb
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 をヘッダーに付け替えて送信してくれます。便利です。

front/lib/axios.ts
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 して使用します。

front/pages/hoge.ts
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 のセキュリティについて議論したいエンジニアも募集しています!

https://careers.leaner.co.jp/

リーナーテックブログ

Discussion

kaibakaiba

いい記事ありがとうございます。

      domain: 'example.com', # <= ここに , が足りていませんでした
      value: form_authenticity_token
corocncorocn

めちゃめちゃ申し訳ない。いま気づきました!修正しておきます〜