has_secure_password の 72 文字制限にご用心

Rails にはパスワードフィールドを管理する方法として `has_secure_password` という機能がある。機能自体については[公式のドキュメント](https://guides.rubyonrails.org/active_model_basics.html#securepassword)を参照してほしい。

本題はこの機能の 72 文字制限についてである。公式のドキュメントにもある通り、この機能が作るパスワードフィールドには最大 72 文字のバリデーションが自動的にかかる。

```irb
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}>]>
```
[ソースコード]( https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activemodel/lib/active_model/secure_password.rb#L108)を追えば確かに制限をかけている箇所が見つかる。

```ruby
validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
```

### 制限の由来

なぜこのようなバリデーションがかけられるかというと、バックエンドの C ライブラリである bcrypt 自身が 72 bytes を超えるデータを切り捨てて処理を行うことが理由だ。

一応、この制限を望まない場合は `has_secure_password` に `validations: false` オプションをつけることでバリデーションを生成させないようにすることが可能ではある。

```ruby
class User < ApplicationRecord
  has_secure_password validations: false
end
```

ただし、当然バックエンドの bcrypt 自身の挙動を変更できるわけではないため、このようにすると 72 bytes を超える部分は単に無視されることになる。

```irb
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
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 が新たにリリースされた](https://rubyonrails.org/2023/10/5/Rails-7-1-0-has-been-released)。このバージョンでは `authenticate_by` など `has_secure_password` 周りのアップデートがいくつか行われている。改めて本件についてのバリデーションを確認してみたところ、該当部分は単なる長さバリデーションからバイト数の検証に変更となっていた。

[Rails 7.0](https://github.com/rails/rails/blob/7-0-stable/activemodel/lib/active_model/secure_password.rb#L108):
```Ruby
validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
```

[Rails 7.1](https://github.com/rails/rails/blob/d39db5d1891f7509cde2efc425c9d69bbb77e670/activemodel/lib/active_model/secure_password.rb#L136):
```Ruby
# 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 では発生しない。折を見てアップグレードしよう。