-
Notifications
You must be signed in to change notification settings - Fork 153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Password complexity validation with ReDOS vulnerability #31
Conversation
Pull Request Test Coverage Report for Build 105
💛 - Coveralls |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your PR. Here are some initial thoughts.
README.md
Outdated
@@ -62,7 +62,7 @@ Devise.setup do |config| | |||
# config.expire_password_after = 3.months | |||
|
|||
# Need 1 char of A-Z, a-z and 0-9 | |||
# config.password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ | |||
# config.password_complexity = { digit: 1, lower: 1, upper: 1 } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps a default for punct
as well, also it would be good to explain that these values represent counts of the POSIX character classes. For the punct
one you would also want to include an example of which characters match that.
In general, I would use configuration keys that are semantically meaningful to users, not developers. The error messages you generate will be displayed to end users and need to be something they are going to be able to comprehend. Your average user won't understand why +
or <
are not considered to be punctuation.
# Options: | ||
# - digit: minimum number of digits in the validated string | ||
# - lower: minimum number of lower-case letters in the validated string | ||
# - punct: minimum number of punctuation characters or symbols in the validated string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the punct
POSIX class misses some characters that most people consider symbols.
In general, it might be more flexible to simply use the Character Properties feature of regexes to do the counts.
With this approach the regular expressions you would want to use are:
digit: /\p{Digit}/
lower: /\p{Lower}/
upper: /\p{Upper}/
symbol: /\p{Punct}|\p{S}/
In general, I think these regexes will be more liberal than the posix classes and will probably handle international characters (like ß
better).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a related note, this approach does nothing to protect against sets of repeated characters or sequences.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree on the character classes, that makes sense.
Not sure what you mean about 'sets of repeated characters or sequences', though? Each regex is for a single character so I'm not sure how repeated characters or sequences in the input would cause a problem?
Another approach would be simply to iterate over each character in the string in turn, bumping the count for any character set it meets (using unpack('U')
or similar) and do away with regexen altogether. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some recommendations for password complexity suggest that you should avoid passwords with long sets of repeated characters (i.e. aaaaaa
) and sequential characters (abcdefg
or 1234
). This is outside the scope of this specific PR as the original code didn't do that either.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using unpack('U')
is an interesting approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see. When you said 'protect against', I thought you were talking about protecting against ReDOS attacks (which, incidentally, can use repetition, alternation, etc).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just about to head home for the weekend, but I should be able to update this PR on Monday (Australia time - so that's Sunday eve from your perspective :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And yes, agreed repeated characters would be useful to protect against (but out of scope for this PR, which is just to use a safer method than asking developers to write a regex). Another common request is avoidance of common dictionary words.
In fact, I don't really think much of the usual complexity rules; I agree with Jeff Atwood that password rules are bullsh**. (And indeed Bill Burr.) I'd much rather use a validator like this one:
https://github.com/cmer/nobspw
However, our clients in their wisdom want the usual kind of complexity rules, based on outdated standards...and they're paying the bills 😂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. The goal here is to do a good job providing the service, even if the wisdom of this approach is now in question.
@olbrich, I think I've addressed your feedback regarding character classes. Also, as I was adjusting the tests, I found that the I considered making the validator accept a Proc, so that configs could be changed at runtime. However, this felt like deforming production code to make testing easier, which wouldn't be good. So instead, I added a unit test for the validator itself, with some dummy models to test various scenarios. See |
…er classes and thus fix ReDOS vulnerability
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new tests should run and pass
test/test_complexity_validator.rb
Outdated
end | ||
|
||
class EmailValidatorTest < Minitest::Test | ||
def with_no_rules_anything_goes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests don't execute. They need to start with test_
or use the test 'description' do..
block style (my preference, actually).
test/test_complexity_validator.rb
Outdated
validates :complex_string, 'devise_security/complexity': { lower: 1, upper: 1, digit: 1, symbol: 1 } | ||
end | ||
|
||
class EmailValidatorTest < Minitest::Test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't validating emails, should be named PasswordComplexityValidatorTest
test/test_complexity_validator.rb
Outdated
assert(NoRules.new('aaaa').valid?) | ||
end | ||
|
||
def enforces_uppercase |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is another pattern here that may be a bit more explicit.
class PasswordComplexityValidatorTest
class Password
include ActiveModel::Validations
attr_reader :password
def initialize(password)
@password = password
end
def setup
Password.clear_validators!
end
def test_enforces_uppercase
Password.validates :password, 'devise_security/complexity': { upper: 1 }
refute(Password.new('aaaa').valid?)
assert(Password.new('Aaaa').valid?)
end
...
# - upper: minimum number of upper-case letters in the validated string | ||
class ComplexityValidator < ActiveModel::EachValidator | ||
PATTERNS = { | ||
digit: /\p{Digit}/, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's likely that some people will try to use digits: 2
or symbols: 2
in the configuration, which is silently fail to do the right thing. Consider either making them aliases, or checking the keys to make sure they match the set that can be used.
test/test_secure_validatable.rb
Outdated
user = User.create email: 'bob@microsoft.com', password: 'PASSWORD1', password_confirmation: 'PASSWORD1' | ||
assert_equal(false, user.valid?) | ||
assert_equal([msg], user.errors.full_messages) | ||
assert_raises(ActiveRecord::RecordInvalid) { user.save! } | ||
end | ||
|
||
test 'password must have number' do | ||
msg = 'Password must contain big, small letters and digits' | ||
msg = 'Password must contain at least one numeral' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
replace numeral
with digit
for consistency.
# - lower: minimum number of lower-case letters in the validated string | ||
# - symbol: minimum number of punctuation characters or symbols in the validated string | ||
# - upper: minimum number of upper-case letters in the validated string | ||
class ComplexityValidator < ActiveModel::EachValidator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
name this PasswordComplexityValidator
. You'll also need to rename all the associated tests/files.
👍 I like the approach of testing the validator independently. |
I think I have addressed all second-round feedback..? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO fine to ignore Replace class var @@password_complexity with a class instance var.
but the rest of the CC/Rubocop warnings should be cleaned up.
@@ -48,7 +49,7 @@ def self.included(base) | |||
|
|||
# extra validations | |||
validates :email, email: email_validation if email_validation # use rails_email_validator or similar | |||
validates :password, format: { with: password_regex, message: :password_format }, if: :password_required? | |||
validates :password, 'devise_security/password_complexity': password_complexity, if: :password_required? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you fix this Rubocop warning while you're in here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line length? Sure, done, but now CodeClimate is complaining there are too many LoC! 😂
@@ -0,0 +1,29 @@ | |||
module DeviseSecurity |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file has a bunch of Rubocop warnings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
}.freeze | ||
|
||
def validate_each(record, attribute, value) | ||
(options.keys & PATTERNS.keys).each do |key| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be able to pull options.keys & PATTERNS.keys
into a separate method to help with complexity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, that worked.
…ty' of github.com:COzero/devise-security into feature/complexity-validation-without-redos-vulnerability
As I mentioned (in #24), the devise-security gem currently is likely (depending on what password_regex is used) to open up a vulnerability to ReDOS attacks.
Fixing this cannot be done without changing the means by which password complexity is set - it's the fact that a single regex is used that's the problem. This unavoidably means a breaking change.
I have implemented a complexity validator using what I believe is the most commonly required POSIX character sets (upper-case letters, lower-case letters, numerals, and punctuation marks/symbols). Client code can specify how many of each they want, or leave out any sets they don't want.
I also added a French translation, which is not great as French is not my native language, but I figured as I knew more French than German or Spanish I may as well add that in too while I was about it :)