[2.2.x] Fixed CVE-2021-45115 -- Prevented DoS vector in UserAttributeSimilarityValidator.
Thanks Chris Bailey for the report. Co-authored-by: Adam Johnson <me@adamj.eu>
This commit is contained in:
parent
03b733d8a8
commit
2135637fdd
|
@ -115,6 +115,36 @@ class MinimumLengthValidator:
|
||||||
) % {'min_length': self.min_length}
|
) % {'min_length': self.min_length}
|
||||||
|
|
||||||
|
|
||||||
|
def exceeds_maximum_length_ratio(password, max_similarity, value):
|
||||||
|
"""
|
||||||
|
Test that value is within a reasonable range of password.
|
||||||
|
|
||||||
|
The following ratio calculations are based on testing SequenceMatcher like
|
||||||
|
this:
|
||||||
|
|
||||||
|
for i in range(0,6):
|
||||||
|
print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio())
|
||||||
|
|
||||||
|
which yields:
|
||||||
|
|
||||||
|
1 1.0
|
||||||
|
10 0.18181818181818182
|
||||||
|
100 0.019801980198019802
|
||||||
|
1000 0.001998001998001998
|
||||||
|
10000 0.00019998000199980003
|
||||||
|
100000 1.999980000199998e-05
|
||||||
|
|
||||||
|
This means a length_ratio of 10 should never yield a similarity higher than
|
||||||
|
0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be
|
||||||
|
calculated via 2 / length_ratio. As a result we avoid the potentially
|
||||||
|
expensive sequence matching.
|
||||||
|
"""
|
||||||
|
pwd_len = len(password)
|
||||||
|
length_bound_similarity = max_similarity / 2 * pwd_len
|
||||||
|
value_len = len(value)
|
||||||
|
return pwd_len >= 10 * value_len and value_len < length_bound_similarity
|
||||||
|
|
||||||
|
|
||||||
class UserAttributeSimilarityValidator:
|
class UserAttributeSimilarityValidator:
|
||||||
"""
|
"""
|
||||||
Validate whether the password is sufficiently different from the user's
|
Validate whether the password is sufficiently different from the user's
|
||||||
|
@ -130,19 +160,25 @@ class UserAttributeSimilarityValidator:
|
||||||
|
|
||||||
def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
|
def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7):
|
||||||
self.user_attributes = user_attributes
|
self.user_attributes = user_attributes
|
||||||
|
if max_similarity < 0.1:
|
||||||
|
raise ValueError('max_similarity must be at least 0.1')
|
||||||
self.max_similarity = max_similarity
|
self.max_similarity = max_similarity
|
||||||
|
|
||||||
def validate(self, password, user=None):
|
def validate(self, password, user=None):
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
password = password.lower()
|
||||||
for attribute_name in self.user_attributes:
|
for attribute_name in self.user_attributes:
|
||||||
value = getattr(user, attribute_name, None)
|
value = getattr(user, attribute_name, None)
|
||||||
if not value or not isinstance(value, str):
|
if not value or not isinstance(value, str):
|
||||||
continue
|
continue
|
||||||
value_parts = re.split(r'\W+', value) + [value]
|
value_lower = value.lower()
|
||||||
|
value_parts = re.split(r'\W+', value_lower) + [value_lower]
|
||||||
for value_part in value_parts:
|
for value_part in value_parts:
|
||||||
if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() >= self.max_similarity:
|
if exceeds_maximum_length_ratio(password, self.max_similarity, value_part):
|
||||||
|
continue
|
||||||
|
if SequenceMatcher(a=password, b=value_part).quick_ratio() >= self.max_similarity:
|
||||||
try:
|
try:
|
||||||
verbose_name = str(user._meta.get_field(attribute_name).verbose_name)
|
verbose_name = str(user._meta.get_field(attribute_name).verbose_name)
|
||||||
except FieldDoesNotExist:
|
except FieldDoesNotExist:
|
||||||
|
|
|
@ -7,4 +7,16 @@ Django 2.2.26 release notes
|
||||||
Django 2.2.26 fixes one security issue with severity "medium" and two security
|
Django 2.2.26 fixes one security issue with severity "medium" and two security
|
||||||
issues with severity "low" in 2.2.25.
|
issues with severity "low" in 2.2.25.
|
||||||
|
|
||||||
...
|
CVE-2021-45115: Denial-of-service possibility in ``UserAttributeSimilarityValidator``
|
||||||
|
=====================================================================================
|
||||||
|
|
||||||
|
:class:`.UserAttributeSimilarityValidator` incurred significant overhead
|
||||||
|
evaluating submitted password that were artificially large in relative to the
|
||||||
|
comparison values. On the assumption that access to user registration was
|
||||||
|
unrestricted this provided a potential vector for a denial-of-service attack.
|
||||||
|
|
||||||
|
In order to mitigate this issue, relatively long values are now ignored by
|
||||||
|
``UserAttributeSimilarityValidator``.
|
||||||
|
|
||||||
|
This issue has severity "medium" according to the :ref:`Django security policy
|
||||||
|
<security-disclosure>`.
|
||||||
|
|
|
@ -522,10 +522,16 @@ Django includes four validators:
|
||||||
is used: ``'username', 'first_name', 'last_name', 'email'``.
|
is used: ``'username', 'first_name', 'last_name', 'email'``.
|
||||||
Attributes that don't exist are ignored.
|
Attributes that don't exist are ignored.
|
||||||
|
|
||||||
The minimum similarity of a rejected password can be set on a scale of 0 to
|
The maximum allowed similarity of passwords can be set on a scale of 0.1
|
||||||
1 with the ``max_similarity`` parameter. A setting of 0 rejects all
|
to 1.0 with the ``max_similarity`` parameter. This is compared to the
|
||||||
passwords, whereas a setting of 1 rejects only passwords that are identical
|
result of :meth:`difflib.SequenceMatcher.quick_ratio`. A value of 0.1
|
||||||
to an attribute's value.
|
rejects passwords unless they are substantially different from the
|
||||||
|
``user_attributes``, whereas a value of 1.0 rejects only passwords that are
|
||||||
|
identical to an attribute's value.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.2.26
|
||||||
|
|
||||||
|
The ``max_similarity`` parameter was limited to a minimum value of 0.1.
|
||||||
|
|
||||||
.. class:: CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH)
|
.. class:: CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH)
|
||||||
|
|
||||||
|
|
|
@ -150,13 +150,10 @@ class UserAttributeSimilarityValidatorTest(TestCase):
|
||||||
max_similarity=1,
|
max_similarity=1,
|
||||||
).validate(user.first_name, user=user)
|
).validate(user.first_name, user=user)
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % "first name"])
|
self.assertEqual(cm.exception.messages, [expected_error % "first name"])
|
||||||
# max_similarity=0 rejects all passwords.
|
# Very low max_similarity is rejected.
|
||||||
with self.assertRaises(ValidationError) as cm:
|
msg = 'max_similarity must be at least 0.1'
|
||||||
UserAttributeSimilarityValidator(
|
with self.assertRaisesMessage(ValueError, msg):
|
||||||
user_attributes=['first_name'],
|
UserAttributeSimilarityValidator(max_similarity=0.09)
|
||||||
max_similarity=0,
|
|
||||||
).validate('XXX', user=user)
|
|
||||||
self.assertEqual(cm.exception.messages, [expected_error % "first name"])
|
|
||||||
# Passes validation.
|
# Passes validation.
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
UserAttributeSimilarityValidator(user_attributes=['first_name']).validate('testclient', user=user)
|
UserAttributeSimilarityValidator(user_attributes=['first_name']).validate('testclient', user=user)
|
||||||
|
|
Loading…
Reference in New Issue