diff --git a/config/locales/en.yml b/config/locales/en.yml index 260e1c4ba6..9439f2f242 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -63,3 +63,4 @@ en: not_saved: one: "1 error prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" + password_too_long_for_bcrypt: "too long (maximum is 72 bytes)" diff --git a/devise.gemspec b/devise.gemspec index 1caa6aeb39..e5c8ab5e7b 100644 --- a/devise.gemspec +++ b/devise.gemspec @@ -32,4 +32,11 @@ Gem::Specification.new do |s| s.add_dependency("bcrypt", "~> 3.0") s.add_dependency("railties", ">= 7.0") s.add_dependency("responders") + + s.post_install_message = %q{ +[DEVISE] Devise now strictly enforces a 72-byte limit on passwords. +This prevents a known BCrypt security issue where passwords exceeding 72 bytes are silently truncated, potentially causing hash collisions. + +This new validation runs automatically alongside your existing character length checks, specifically targeting passwords with heavy multi-byte characters (like emojis) that might look short but are large in memory. + } end diff --git a/lib/devise.rb b/lib/devise.rb index 8e0c85e77d..7912de57d2 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -117,7 +117,7 @@ module Test # Range validation for password length mattr_accessor :password_length - @@password_length = 6..128 + @@password_length = 6..72 # max 72 bytes for bcrypt # The time the user will be remembered without asking for credentials again. mattr_accessor :remember_for diff --git a/lib/devise/models/validatable.rb b/lib/devise/models/validatable.rb index 62486cfbe0..83ea97a9ba 100644 --- a/lib/devise/models/validatable.rb +++ b/lib/devise/models/validatable.rb @@ -12,7 +12,7 @@ module Models # Validatable adds the following options to +devise+: # # * +email_regexp+: the regular expression used to validate e-mails; - # * +password_length+: a range expressing password length. Defaults to 6..128. + # * +password_length+: a range expressing password length. Defaults to 6..72. # # Since +password_length+ is applied in a proc within `validates_length_of` it can be overridden # at runtime. @@ -21,6 +21,9 @@ module Validatable VALIDATIONS = [:validates_presence_of, :validates_uniqueness_of, :validates_format_of, :validates_confirmation_of, :validates_length_of].freeze + # maximum allowed bytes for BCrypt (72 bytes) + MAX_PASSWORD_BCRYPT_LENGTH_ALLOWED = 72 + def self.required_fields(klass) [] end @@ -37,6 +40,8 @@ def self.included(base) validates_presence_of :password, if: :password_required? validates_confirmation_of :password, if: :password_required? validates_length_of :password, minimum: proc { password_length.min }, maximum: proc { password_length.max }, allow_blank: true + + validate :max_password_length_for_bcrypt end end @@ -62,6 +67,16 @@ def email_required? true end + # Validates that the password does not exceed the maximum allowed bytes for BCrypt (72 bytes) + def max_password_length_for_bcrypt + if password.present? + password_already_too_long = self.errors.where(:password, :too_long).present? + if !password_already_too_long && password.bytesize > MAX_PASSWORD_BCRYPT_LENGTH_ALLOWED + self.errors.add(:password, :password_too_long_for_bcrypt) + end + end + end + module ClassMethods Devise::Models.config(self, :email_regexp, :password_length) end diff --git a/lib/generators/templates/devise.rb b/lib/generators/templates/devise.rb index b36f281f25..c5bb0c0c02 100644 --- a/lib/generators/templates/devise.rb +++ b/lib/generators/templates/devise.rb @@ -180,8 +180,8 @@ # config.rememberable_options = {} # ==> Configuration for :validatable - # Range for password length. - config.password_length = 6..128 + # Range for password length. 72 bytes max for bcrypt + config.password_length = 6..72 # Email regex used to validate email formats. It simply asserts that # one (and only one) @ exists in the given string. This is mainly diff --git a/test/models/validatable_test.rb b/test/models/validatable_test.rb index e8858de7e3..fb7e7af594 100644 --- a/test/models/validatable_test.rb +++ b/test/models/validatable_test.rb @@ -92,6 +92,14 @@ class ValidatableTest < ActiveSupport::TestCase assert_equal 'is too long (maximum is 72 characters)', user.errors[:password].join end + test 'should validate that password cannot be bigger that 72 bytes for bcrypt' do + Devise.stubs(:password_length).returns(6..512) + password = '🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠🫠' + user = new_user(password: password, password_confirmation: password) + assert user.invalid? + assert_equal 'too long (maximum is 72 bytes)', user.errors[:password].join + end + test 'should not require password length when it\'s not changed' do user = create_user.reload user.password = user.password_confirmation = nil