はじめに
SinatraやPadrinoでSession管理に用いられるRack::Session::Cookieは、Session Cookieに全セッション情報を保持してしまう為、使いたくありません。代わりにRack::Session::Poolを用いようとしたのですが、CSRFを誤検知するようになってしまいました。
そもそも、Rack::Session::PoolやRack::Protection::AuthenticityTokenに関する情報が少なく、正確でない情報やCSRF対策を無効にするような記事もあったので、調査結果と対策を残しておきます。
環境
バージョン | |
---|---|
OS | macOS 10.14.5 |
Ruby | 2.6.3 |
Sinatra | 2.0.5 |
Padrino | 0.14.4 |
現象
Sinatraの公式ドキュメントでは、Rack::Session::Poolの使い方を以下のように解説しています。
set :sessions, :expire_after => 2592000
set :session_store, Rack::Session::Pool
しかし、これだけではRack::Protectionが有効にならないとの記述があります。その為、自分で明示的にRack::Protectionミドルウェアを利用する必要があります。
この方法を選択する場合は、セッションベースの保護はデフォルトで有効にならないということに注意することが重要です。
use Rack::Session::Pool, :expire_after => 2592000 use Rack::Protection::RemoteToken use Rack::Protection::SessionHijacking
このことから、CSRFを防御したい場合はRack::Protection::AuthenticityTokenを追記すればと考えると思います。
use Rack::Session::Pool, :expire_after => 2592000
use Rack::Protection::AuthenticityToken
しかし、この設定ではCSRFトークンの受け渡し方法(hiddenタグ等、またはHTTP_X_CSRF_TOKENヘッダ)に関わらずCSRF攻撃を受けたと判定され、「attack reported by Rack::Protection::AuthenticityToken」と報告されてしまいます。
解説
AuthenticityTokenのCSRFトークン検証ロジックは以下の通りです。
# Checks the client's masked token to see if it matches the # session token. def valid_token?(session, token) return false if token.nil? || token.empty? begin token = decode_token(token) rescue ArgumentError # encoded_masked_token is invalid Base64 return false end # See if it's actually a masked token or not. We should be able # to handle any unmasked tokens that we've issued without error. if unmasked_token?(token) compare_with_real_token token, session elsif masked_token?(token) token = unmask_token(token) compare_with_real_token token, session else false # Token is malformed end end # ...中略... def decode_token(token) Base64.strict_decode64(token) end
このdecode_token(token)が問題で、CSRFトークンは必ずBASE64エンコードされている前提で検証されています。Rack::Session::Cookieは、(普通に利用する限り)セッションを書き出す際にBASE64エンコードするのですが、Rack::Session::Poolは何もしません。セッション情報をメモリ上にHashとして持ち続けるだけなのですから、当然といえば当然です。
対策
Rackコンポーネントを修正しない場合、CSRFトークンをBASE64エンコードするしかありません。幸い、Rack::Protection::AuthenticityTokenは今回のような場合に利用できる便利なメソッドを持っています。
# hiddenタグでCSRFトークンをやり取りする場合の例(erb)
<input type="hidden" name="authenticity_token" value="<%= Rack::Protection::AuthenticityToken.token(session) %>" />
AuthenticityToken#tokenで、BASE64エンコードだけでなくマスク処理もしてくれます。これで、Rack::Session::PoolとRack::Protection::AuthenticityTokenを組み合わせても正しく動作します。
上述はSinatraを想定していますが、Padrinoも基本的な考え方は変わりません。ただし、PadrinoにはRack::Protection::AuthenticityTokenをPadrino向けに拡張したPadrino::AuthenticityTokenがありますので、そちらを利用するとよいと思います。
# Padrinoの場合(FormHelperを利用)
hidden_field_tag(csrf_param, value: Padrino::AuthenticityToken.token(session))