Rails にはパスワードフィールドを管理する方法として has_secure_password
という機能がある。機能自体については公式のドキュメントを参照してほしい。
本題はこの機能の 72 文字制限についてである。公式のドキュメントにもある通り、この機能が作るパスワードフィールドには最大 72 文字のバリデーションが自動的にかかる。
irb> user.password = user.password_confirmation = 'a' * 73
irb> user.valid?
=> false
irb> user.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=password, type=too_long, options={:count=>72}>]>
ソースコードを追えば確かに制限をかけている箇所が見つかる。
validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
制限の由来
なぜこのようなバリデーションがかけられるかというと、バックエンドの C ライブラリである bcrypt 自身が 72 bytes を超えるデータを切り捨てて処理を行うことが理由だ。
一応、この制限を望まない場合は has_secure_password
に validations: false
オプションをつけることでバリデーションを生成させないようにすることが可能ではある。
class User < ApplicationRecord
has_secure_password validations: false
end
ただし、当然バックエンドの bcrypt 自身の挙動を変更できるわけではないため、このようにすると 72 bytes を超える部分は単に無視されることになる。
irb> user.password = user.password_confirmation = 'a' * 72 + 'b'
irb> user.account_name = 'test'
irb> user.save
=> true
irb> user.authenticate('a' * 72 + 'c')
=> #<User: ...>
従って、デフォルトで生成されるこのバリデーションをむやみに外してしまうことは望ましくないだろう。
chars?bytes?
なるほど、なぜデフォルトでこのようなバリデーションが生成されるのかはわかった。バックエンドの bcrypt が 72 bytes までしか扱ってくれないのであれば仕方ない。だが、何か違和感がないだろうか…?
…ん? bytes?
そう、バックエンドの bcrypt による制限は bytes である。しかし Rails の文字列長バリデーションのカウントは文字数だ。したがってデフォルトで生成されるバリデーションは実は不十分で、パスワードにマルチバイト文字が含まれていると bcrypt による切り捨てが発生してしまう。
irb> user.password = user.password_confirmation = 'a' * 71 + 'あ'
irb> user.account_name = 'test'
irb> user.save
=> true
irb> user.authenticate('a' * 71 + 'い')
=> #<User: ...>
この例でパスワードに設定しているものは、文字数としては 72 文字であるためバリデーションにひっかかることはない。しかし、最後の文字を い
に変えたもので認証を試すと、異なる文字列であるにも関わらず認証は通ってしまう。UTF-8 バイト列で解釈すると あ
は e3 81 82
、 い
は e3 81 84
であり、パスワード全体としては 74 bytes。このため末尾の 2 bytes が bcrypt によって破棄されてしまい、結果として一致してしまうのが原因というわけだ。
まとめ
has_secure_password
はバックエンドの制限を鑑みて自動的にバリデーションを付与してくれるが、実はそれでは不足するパターンが存在する。現実的にこのことが問題になることは滅多にないとは思われるが、セキュリティ関係の話ではあるのでなるべく厳密に対応しておくに越したことはない。
この場合は、自分で 72 bytes の制限をかける、パスワードとして有効な文字種は 1 byte で表現できる範囲に制限する、といったことを検討すると良いだろう。
2023-10-05 追記
本日、 Rails 7.1 が新たにリリースされた。このバージョンでは authenticate_by
など has_secure_password
周りのアップデートがいくつか行われている。改めて本件についてのバリデーションを確認してみたところ、該当部分は単なる長さバリデーションからバイト数の検証に変更となっていた。
validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
# Validates that the password does not exceed the maximum allowed bytes for BCrypt (72 bytes).
validate do |record|
password_value = record.public_send(attribute)
if password_value.present? && password_value.bytesize > ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
record.errors.add(attribute, :password_too_long)
end
end
このため本記事の問題は 7.1 では発生しない。折を見てアップグレードしよう。