Rails
csrf

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

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]の値です。

関連ページ