Posted at

RailsでのCSRF token 発行 / 検証のロジック

More than 1 year has passed since last update.


RailsでのCSRF対策

RailsではdefaultでFormHelperjquery-railsによりauthenticity tokenをリクエストに追加して送り、サーバーで検証するというCSRF対策がされています。rails csrfと検索すると関連するページがたくさん見つかります。

今回は普段は気にしなくても良い、token発行や検証の仕組みを確認してみました。


ソースコード

ロジックはすべてAction Packのrequest_forgery_protection.rbに実装されていて、実際にコードを確認しました。


Authenticity Token 発行

まずはToken発行の仕組みを見ていきます。

form_authenticity_tokenというhelper methodがviewからアクセスできるようになっており、tokenを発行するインターフェースとなっており、その中で呼ばれているmasked_authenticity_tokenでtokenの発行ロジックが記述されています。

def masked_authenticity_token(session, form_options: {}) # :doc:

action, method = form_options.values_at(:action, :method)

raw_token = if per_form_csrf_tokens && action && method
action_path = normalize_action_path(action)
per_form_csrf_token(session, action_path, method)
else
real_csrf_token(session)
end

one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
masked_token = one_time_pad + encrypted_csrf_token
Base64.strict_encode64(masked_token)
end


raw_token

  ...

raw_token = if per_form_csrf_tokens && action && method
action_path = normalize_action_path(action)
per_form_csrf_token(session, action_path, method)
else
...

per_form_csrf_tokenを見ると、セッションに保存されたcsrf tokenとaction, method を組み合わせてSHA256 ハッシュを作成しています。これが raw_tokenとして使われています。


session[:_csrf_token]

def real_csrf_token(session) # :doc:

session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
Base64.strict_decode64(session[:_csrf_token])
end

raw_tokenの計算に使われるsassion[:_csrf_token]SecureRandom.base64で作られたランダムな文字列が保存されていて、同じものを同じセッションで使い回されていてます。


masked_token

  ...

one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
masked_token = one_time_pad + encrypted_csrf_token
...

生成されたraw_tokenone_time_padを合わせてmasked_tokenが生成されています。

one_time_padraw_tokenと同じ長さのランダムなバイト列で、xor_byte_stringsというmethodでraw_tokenにマスクするのに使われています。このmethodは検証にも使われます。


authenticity token

  ...

Base64.strict_encode64(masked_token)
end

masked_tokenをbase64でencodeしたものがauthenticity tokenとして、HTMLページに埋め込まれます。


Authenticity Token 検証

次にToken検証のロジックを見ていきます。

全てのリクエストはbefore_actionverify_authenticity_tokenが実行されていて、authenticity tokenの検証が必要なリクエストに対して検証ロジックが実行されます。

ロジック自体はvalid_authenticity_token?に記述されています。

def valid_authenticity_token?(session, encoded_masked_token) # :doc:

...
begin
masked_token = Base64.strict_decode64(encoded_masked_token)
rescue ArgumentError # encoded_masked_token is invalid Base64
return false
end

if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
compare_with_real_token masked_token, session
elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
csrf_token = unmask_token(masked_token)
compare_with_real_token(csrf_token, session) ||
valid_per_form_csrf_token?(csrf_token, session)
else
false # Token is malformed.
end
end


decode token

  masked_token = Base64.strict_decode64(encoded_masked_token)  

まずはリクエストで送られてきたtokenをBase64でdecodeします


unmasked tokenの場合

  if masked_token.length == AUTHENTICITY_TOKEN_LENGTH

compare_with_real_token masked_token, session
elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
...

masked_tokenという変数名ですが、長さがAUTHENTICITY_TOKEN_LENGTHと同じ場合はmaskされていないものとして、セッション内に保存されたcsrf tokenと直接比較しています。


masked tokenの場合

  elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2

csrf_token = unmask_token(masked_token)
compare_with_real_token(csrf_token, session) ||
valid_per_form_csrf_token?(csrf_token, session)
else
...

変数masked_tokenの長さがAUTHENTICITY_TOKEN_LENGTHの2倍の場合は、maskされているものとして、unmask_tokenをしてから、compare_with_real_token実行されています。


unmask_token

def unmask_token(masked_token) # :doc:

# Split the token into the one-time pad and the encrypted
# value and decrypt it.
one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
xor_byte_strings(one_time_pad, encrypted_csrf_token)
end

コメントにあるように、tokenを半分に分割します。

ここでxor_byte_stringsone_time_padencrypted_csrf_tokenを実行することで、encrypted_csrf_tokenのmaskを外しています。

maskが外されたcsrf_tokenとセッション内に保存されたcsrf tokenを比較して一致するか比較しています。


Demo

ロジックがわかれば、HTMLページ内のauthenticity_tokenからセッションに保存されている CSRF tokenが計算することができます。

https://gist.github.com/Jwata/4e5122fa43d719400914716955872cc2

> git clone https://gist.github.com/Jwata/4e5122fa43d719400914716955872cc2 authenticity_token_to_csrf_token

> rails runner ./authenticity_token_to_csrf_token/calculate.rb 'S5O27L72mhZRQk4rP16sIbj2m0BI7SrujJyh+hEXoaeTaT6GHxXx6d7UFnU05z6VxWFgb4wG+AtFsuHb5mqQ/A=='
=> 2PqIaqHja/+PllheC7mStH2X+y/E69LlyS5AIfd9MVs=

> rails runner ./authenticity_token_to_csrf_token/calculate.rb 'JU6qRx/avhFPb/8+F3oZl/Iabr3nSbWVIgSHPm0Bz9n9tCItvjnV7sD5p2Acw4sjj42VkiOiZ3DrKscfmnz+gg=='
=> 2PqIaqHja/+PllheC7mStH2X+y/E69LlyS5AIfd9MVs=

この場合は2PqIaqHja/+PllheC7mStH2X+y/E69LlyS5AIfd9MVs=session[:_csrf_token]の値です。


関連ページ