【Ruby on Rails】サーバ間で authenticity token がマッチせず、フォーム送信に失敗していた時の話

概要

Ruby on Rails でアプリケーションを作成すると ApplicationController の中に以下のコードがデフォルトで記載されています。

# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception

これは CSRF(クロスサイトリクエストフォージェリ) 対策用のセキュリティトークンの有無を表しており、このコードがある場合は、生成される全てのフォームと Ajax リクエストで GET 以外の HTTP メソッド(POST, DELETE, PUT, PATCH)を使う場合 hidden_field の中に自動的に authenticity_token が含まれます。

参考:Rails セキュリティガイド - Rails ガイド

authenticity_token の値は自分のアプリケーションだけが把握しており、リクエストに含まれる authenticity_token の値が一致しない場合は以下の例外をスローします。

Can't verify CSRF token authenticity.

ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):

今回、このセキュリティートークンが原因で、本番環境にリリースしたサーバでフォーム送信が 1/2 の確率で失敗していた時の話をします。

 

状況の整理

開発環境で動作確認して問題がなかったので、本番環境にリリースしたら 1/2 の確率でフォーム送信が失敗している事に気付きました。

エラーログには Can't verify CSRF token authentivity. と出ていました。 

本番環境はロードバランサー配下に 2 台のサーバをぶら下げている状況で、ロードバランサーに DNS を登録しているので、ユーザーからのリクエストはロードバランサーを経由して各サーバに分散されます。

調べていたら、どうもフォームの送信元と送信先のサーバが異なる場合にリクエストに失敗している事に気付きました。

つまり、以下のような結果となります。

  • サーバ A ➡︎ サーバ A:成功
  • サーバ A ➡︎ サーバ B:失敗
  • サーバ B ➡︎ サーバ A:失敗
  • サーバ B ➡︎ サーバ B:成功

 

原因

これは、サーバ A がフォーム送信に含める authenticity_token の値と、サーバ B が期待する authenticity_token の値が異なるのが原因です。

サーバ B はサーバ A がリクエストに含める authenticity_token の値を知らないので当然リクエストに成功することはありません。

ちなみに開発環境で同様のエラーが発生しなかったのは 1 台のサーバで開発して動作確認をしていたからです。

 

対応策

一番簡単なのは ApplicationController に記載された protect_from_forgery with:  :exception を削除する事です。

しかし、わざわざ Rails 側が用意してくれた機能ですし、セキュリティレベルが落ちてしまうので出来れば抜きたくないと思っていました。

そこで、以下の記事を参考に authenticity_token について調べていたら1つ解決策に辿り着きました。

qiita.com

qiita.com

railsguides.jp

 

それは secret_key_base の値をサーバ A とサーバ B で同じにすればフォーム送信に成功すると言う事です。

なぜ成功するようになったのか、詳しい仕組みまでは分からなかったのですが、、、

もし仮説を立てるならば、、、

authenticity_token はセッションに保存されている何かしら値とフォームで使っているアクション名(?)とメソッド名(?)組み合わせて生成しています。

セッションに保存している何かしらの値は config/secrets.yaml に記載された secret_key_base の値を使って暗号化しているのではないかと言う事です。

今まで自分は secret_key_base の値はサーバ毎に決まっており、production 環境では $ bundle exec bin/rake secret で生成した値しか使えないものだと思っていました。

しかし、試して見たところ secret_key_base の値はなんでも良く、$ bundle exec bin/rake secret で生成した値でなくても動きます。

サーバ A とサーバ B のソースコードが同じならばアクション名(?)とメソッド名(?)は基本的に同じなので secret_key_base の値を同じにしてあげればフォーム送信が上手くいくと思います。

 

結論

サーバ A で使っている secret_key_base の値をサーバ B にも設定することで、authenticity_token が含まれたリクエスト(フォーム送信)がサーバ A ⇄ サーバ B 間でも上手くいくことを確認しました。

 

最後に

色々調べたのですが、この仮説が正しいことを証明できる材料は見つかりませんでした。

ソースコードを読めば分かるかもしれませんが、労力がかなりかかりそうだったので途中で断念しました。。

authenticity_token の生成には one_time_pad (ワンタイムパスワード的なもの?)が使われてますし、SHA256 でハッシュ化してる部分もあり、なんか違うような気もしています。。

実際にサーバ A とサーバ B の authenticity_token を比べて見ても同値にはなりませんでした。

ただし、secret_key_base を同じにすると今まで失敗していたフォーム送信が成功するようになったのは事実なので、どこかで関わりがあるのではないかと思っています。

同様の事象で困っている方がいたら試して見てください。

また、事実を知っている方いたらコメントくださいーmm