1 |
From a8b32fe13bcaed1c0b772fdc53de84abc224fb20 Mon Sep 17 00:00:00 2001 |
2 |
From: Florian Apolloner <florian@apolloner.eu> |
3 |
Date: Mon, 27 Dec 2021 14:48:03 +0100 |
4 |
Subject: [PATCH] [3.2.x] Fixed CVE-2021-45115 -- Prevented DoS vector in |
5 |
UserAttributeSimilarityValidator. |
6 |
|
7 |
Thanks Chris Bailey for the report. |
8 |
|
9 |
Co-authored-by: Adam Johnson <me@adamj.eu> |
10 |
--- |
11 |
django/contrib/auth/password_validation.py | 40 ++++++++++++++++++++-- |
12 |
docs/releases/2.2.26.txt | 14 +++++++- |
13 |
docs/releases/3.2.11.txt | 14 +++++++- |
14 |
docs/topics/auth/passwords.txt | 14 +++++--- |
15 |
tests/auth_tests/test_validators.py | 11 +++--- |
16 |
5 files changed, 78 insertions(+), 15 deletions(-) |
17 |
|
18 |
diff --git a/django/contrib/auth/password_validation.py b/django/contrib/auth/password_validation.py |
19 |
index 845f4d86d5b2..7beb4bdc0ff2 100644 |
20 |
--- a/django/contrib/auth/password_validation.py |
21 |
+++ b/django/contrib/auth/password_validation.py |
22 |
@@ -115,6 +115,36 @@ def get_help_text(self): |
23 |
) % {'min_length': self.min_length} |
24 |
|
25 |
|
26 |
+def exceeds_maximum_length_ratio(password, max_similarity, value): |
27 |
+ """ |
28 |
+ Test that value is within a reasonable range of password. |
29 |
+ |
30 |
+ The following ratio calculations are based on testing SequenceMatcher like |
31 |
+ this: |
32 |
+ |
33 |
+ for i in range(0,6): |
34 |
+ print(10**i, SequenceMatcher(a='A', b='A'*(10**i)).quick_ratio()) |
35 |
+ |
36 |
+ which yields: |
37 |
+ |
38 |
+ 1 1.0 |
39 |
+ 10 0.18181818181818182 |
40 |
+ 100 0.019801980198019802 |
41 |
+ 1000 0.001998001998001998 |
42 |
+ 10000 0.00019998000199980003 |
43 |
+ 100000 1.999980000199998e-05 |
44 |
+ |
45 |
+ This means a length_ratio of 10 should never yield a similarity higher than |
46 |
+ 0.2, for 100 this is down to 0.02 and for 1000 it is 0.002. This can be |
47 |
+ calculated via 2 / length_ratio. As a result we avoid the potentially |
48 |
+ expensive sequence matching. |
49 |
+ """ |
50 |
+ pwd_len = len(password) |
51 |
+ length_bound_similarity = max_similarity / 2 * pwd_len |
52 |
+ value_len = len(value) |
53 |
+ return pwd_len >= 10 * value_len and value_len < length_bound_similarity |
54 |
+ |
55 |
+ |
56 |
class UserAttributeSimilarityValidator: |
57 |
""" |
58 |
Validate whether the password is sufficiently different from the user's |
59 |
@@ -130,19 +160,25 @@ class UserAttributeSimilarityValidator: |
60 |
|
61 |
def __init__(self, user_attributes=DEFAULT_USER_ATTRIBUTES, max_similarity=0.7): |
62 |
self.user_attributes = user_attributes |
63 |
+ if max_similarity < 0.1: |
64 |
+ raise ValueError('max_similarity must be at least 0.1') |
65 |
self.max_similarity = max_similarity |
66 |
|
67 |
def validate(self, password, user=None): |
68 |
if not user: |
69 |
return |
70 |
|
71 |
+ password = password.lower() |
72 |
for attribute_name in self.user_attributes: |
73 |
value = getattr(user, attribute_name, None) |
74 |
if not value or not isinstance(value, str): |
75 |
continue |
76 |
- value_parts = re.split(r'\W+', value) + [value] |
77 |
+ value_lower = value.lower() |
78 |
+ value_parts = re.split(r'\W+', value_lower) + [value_lower] |
79 |
for value_part in value_parts: |
80 |
- if SequenceMatcher(a=password.lower(), b=value_part.lower()).quick_ratio() >= self.max_similarity: |
81 |
+ if exceeds_maximum_length_ratio(password, self.max_similarity, value_part): |
82 |
+ continue |
83 |
+ if SequenceMatcher(a=password, b=value_part).quick_ratio() >= self.max_similarity: |
84 |
try: |
85 |
verbose_name = str(user._meta.get_field(attribute_name).verbose_name) |
86 |
except FieldDoesNotExist: |
87 |
diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt |
88 |
index 52c90d574b42..8fc4ba6ed41a 100644 |
89 |
--- a/docs/topics/auth/passwords.txt |
90 |
+++ b/docs/topics/auth/passwords.txt |
91 |
@@ -539,10 +539,16 @@ Django includes four validators: |
92 |
is used: ``'username', 'first_name', 'last_name', 'email'``. |
93 |
Attributes that don't exist are ignored. |
94 |
|
95 |
- The minimum similarity of a rejected password can be set on a scale of 0 to |
96 |
- 1 with the ``max_similarity`` parameter. A setting of 0 rejects all |
97 |
- passwords, whereas a setting of 1 rejects only passwords that are identical |
98 |
- to an attribute's value. |
99 |
+ The maximum allowed similarity of passwords can be set on a scale of 0.1 |
100 |
+ to 1.0 with the ``max_similarity`` parameter. This is compared to the |
101 |
+ result of :meth:`difflib.SequenceMatcher.quick_ratio`. A value of 0.1 |
102 |
+ rejects passwords unless they are substantially different from the |
103 |
+ ``user_attributes``, whereas a value of 1.0 rejects only passwords that are |
104 |
+ identical to an attribute's value. |
105 |
+ |
106 |
+ .. versionchanged:: 2.2.26 |
107 |
+ |
108 |
+ The ``max_similarity`` parameter was limited to a minimum value of 0.1. |
109 |
|
110 |
.. class:: CommonPasswordValidator(password_list_path=DEFAULT_PASSWORD_LIST_PATH) |
111 |
|
112 |
diff --git a/tests/auth_tests/test_validators.py b/tests/auth_tests/test_validators.py |
113 |
index 393fbdd39c8f..f4aaf3305290 100644 |
114 |
--- a/tests/auth_tests/test_validators.py |
115 |
+++ b/tests/auth_tests/test_validators.py |
116 |
@@ -150,13 +150,10 @@ def test_validate(self): |
117 |
max_similarity=1, |
118 |
).validate(user.first_name, user=user) |
119 |
self.assertEqual(cm.exception.messages, [expected_error % "first name"]) |
120 |
- # max_similarity=0 rejects all passwords. |
121 |
- with self.assertRaises(ValidationError) as cm: |
122 |
- UserAttributeSimilarityValidator( |
123 |
- user_attributes=['first_name'], |
124 |
- max_similarity=0, |
125 |
- ).validate('XXX', user=user) |
126 |
- self.assertEqual(cm.exception.messages, [expected_error % "first name"]) |
127 |
+ # Very low max_similarity is rejected. |
128 |
+ msg = 'max_similarity must be at least 0.1' |
129 |
+ with self.assertRaisesMessage(ValueError, msg): |
130 |
+ UserAttributeSimilarityValidator(max_similarity=0.09) |
131 |
# Passes validation. |
132 |
self.assertIsNone( |
133 |
UserAttributeSimilarityValidator(user_attributes=['first_name']).validate('testclient', user=user) |