Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

[1.6.x] Ensure that passwords are never long enough for a DoS.

 * Limit the password length to 4096 bytes
  * Password hashers will raise a ValueError
  * django.contrib.auth forms will fail validation
 * Document in release notes that this is a backwards incompatible change

Thanks to Josh Wright for the report, and Donald Stufft for the patch.

This is a security fix; disclosure to follow shortly.

Backport of aae5a96 from master.
  • Loading branch information...
commit 5ecc0f828ebe270cfc92a0a2bfb4268800907904 1 parent 4c4954a
Russell Keith-Magee authored September 15, 2013
48  django/contrib/auth/forms.py
@@ -15,7 +15,9 @@
15 15
 
16 16
 from django.contrib.auth import authenticate, get_user_model
17 17
 from django.contrib.auth.models import User
18  
-from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher
  18
+from django.contrib.auth.hashers import (
  19
+    MAXIMUM_PASSWORD_LENGTH, UNUSABLE_PASSWORD_PREFIX, identify_hasher,
  20
+)
19 21
 from django.contrib.auth.tokens import default_token_generator
20 22
 from django.contrib.sites.models import get_current_site
21 23
 
@@ -81,9 +83,10 @@ class UserCreationForm(forms.ModelForm):
81 83
             'invalid': _("This value may contain only letters, numbers and "
82 84
                          "@/./+/-/_ characters.")})
83 85
     password1 = forms.CharField(label=_("Password"),
84  
-        widget=forms.PasswordInput)
  86
+        widget=forms.PasswordInput, max_length=MAXIMUM_PASSWORD_LENGTH)
85 87
     password2 = forms.CharField(label=_("Password confirmation"),
86 88
         widget=forms.PasswordInput,
  89
+        max_length=MAXIMUM_PASSWORD_LENGTH,
87 90
         help_text=_("Enter the same password as above, for verification."))
88 91
 
89 92
     class Meta:
@@ -157,7 +160,11 @@ class AuthenticationForm(forms.Form):
157 160
     username/password logins.
158 161
     """
159 162
     username = forms.CharField(max_length=254)
160  
-    password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
  163
+    password = forms.CharField(
  164
+        label=_("Password"),
  165
+        widget=forms.PasswordInput,
  166
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  167
+    )
161 168
 
162 169
     error_messages = {
163 170
         'invalid_login': _("Please enter a correct %(username)s and password. "
@@ -264,10 +271,16 @@ class SetPasswordForm(forms.Form):
264 271
     error_messages = {
265 272
         'password_mismatch': _("The two password fields didn't match."),
266 273
     }
267  
-    new_password1 = forms.CharField(label=_("New password"),
268  
-                                    widget=forms.PasswordInput)
269  
-    new_password2 = forms.CharField(label=_("New password confirmation"),
270  
-                                    widget=forms.PasswordInput)
  274
+    new_password1 = forms.CharField(
  275
+        label=_("New password"),
  276
+        widget=forms.PasswordInput,
  277
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  278
+    )
  279
+    new_password2 = forms.CharField(
  280
+        label=_("New password confirmation"),
  281
+        widget=forms.PasswordInput,
  282
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  283
+    )
271 284
 
272 285
     def __init__(self, user, *args, **kwargs):
273 286
         self.user = user
@@ -300,8 +313,11 @@ class PasswordChangeForm(SetPasswordForm):
300 313
         'password_incorrect': _("Your old password was entered incorrectly. "
301 314
                                 "Please enter it again."),
302 315
     })
303  
-    old_password = forms.CharField(label=_("Old password"),
304  
-                                   widget=forms.PasswordInput)
  316
+    old_password = forms.CharField(
  317
+        label=_("Old password"),
  318
+        widget=forms.PasswordInput,
  319
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  320
+    )
305 321
 
306 322
     def clean_old_password(self):
307 323
         """
@@ -328,10 +344,16 @@ class AdminPasswordChangeForm(forms.Form):
328 344
     error_messages = {
329 345
         'password_mismatch': _("The two password fields didn't match."),
330 346
     }
331  
-    password1 = forms.CharField(label=_("Password"),
332  
-                                widget=forms.PasswordInput)
333  
-    password2 = forms.CharField(label=_("Password (again)"),
334  
-                                widget=forms.PasswordInput)
  347
+    password1 = forms.CharField(
  348
+        label=_("Password"),
  349
+        widget=forms.PasswordInput,
  350
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  351
+    )
  352
+    password2 = forms.CharField(
  353
+        label=_("Password (again)"),
  354
+        widget=forms.PasswordInput,
  355
+        max_length=MAXIMUM_PASSWORD_LENGTH,
  356
+    )
335 357
 
336 358
     def __init__(self, user, *args, **kwargs):
337 359
         self.user = user
29  django/contrib/auth/hashers.py
@@ -2,6 +2,7 @@
2 2
 
3 3
 import base64
4 4
 import binascii
  5
+import functools
5 6
 import hashlib
6 7
 
7 8
 from django.dispatch import receiver
@@ -19,6 +20,7 @@
19 20
 
20 21
 UNUSABLE_PASSWORD_PREFIX = '!'  # This will never be a valid encoded hash
21 22
 UNUSABLE_PASSWORD_SUFFIX_LENGTH = 40  # number of random chars to add after UNUSABLE_PASSWORD_PREFIX
  23
+MAXIMUM_PASSWORD_LENGTH = 4096  # The maximum length a password can be to prevent DoS
22 24
 HASHERS = None  # lazily loaded from PASSWORD_HASHERS
23 25
 PREFERRED_HASHER = None  # defaults to first item in PASSWORD_HASHERS
24 26
 
@@ -31,6 +33,18 @@ def reset_hashers(**kwargs):
31 33
         PREFERRED_HASHER = None
32 34
 
33 35
 
  36
+def password_max_length(max_length):
  37
+    def inner(fn):
  38
+        @functools.wraps(fn)
  39
+        def wrapper(self, password, *args, **kwargs):
  40
+            if len(password) > max_length:
  41
+                raise ValueError("Invalid password; Must be less than or equal"
  42
+                                 " to %d bytes" % max_length)
  43
+            return fn(self, password, *args, **kwargs)
  44
+        return wrapper
  45
+    return inner
  46
+
  47
+
34 48
 def is_password_usable(encoded):
35 49
     if encoded is None or encoded.startswith(UNUSABLE_PASSWORD_PREFIX):
36 50
         return False
@@ -225,6 +239,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
225 239
     iterations = 10000
226 240
     digest = hashlib.sha256
227 241
 
  242
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
228 243
     def encode(self, password, salt, iterations=None):
229 244
         assert password is not None
230 245
         assert salt and '$' not in salt
@@ -234,6 +249,7 @@ def encode(self, password, salt, iterations=None):
234 249
         hash = base64.b64encode(hash).decode('ascii').strip()
235 250
         return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash)
236 251
 
  252
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
237 253
     def verify(self, password, encoded):
238 254
         algorithm, iterations, salt, hash = encoded.split('$', 3)
239 255
         assert algorithm == self.algorithm
@@ -280,6 +296,7 @@ def salt(self):
280 296
         bcrypt = self._load_library()
281 297
         return bcrypt.gensalt(self.rounds)
282 298
 
  299
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
283 300
     def encode(self, password, salt):
284 301
         bcrypt = self._load_library()
285 302
         # Need to reevaluate the force_bytes call once bcrypt is supported on
@@ -297,6 +314,7 @@ def encode(self, password, salt):
297 314
         data = bcrypt.hashpw(password, salt)
298 315
         return "%s$%s" % (self.algorithm, force_text(data))
299 316
 
  317
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
300 318
     def verify(self, password, encoded):
301 319
         algorithm, data = encoded.split('$', 1)
302 320
         assert algorithm == self.algorithm
@@ -353,12 +371,14 @@ class SHA1PasswordHasher(BasePasswordHasher):
353 371
     """
354 372
     algorithm = "sha1"
355 373
 
  374
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
356 375
     def encode(self, password, salt):
357 376
         assert password is not None
358 377
         assert salt and '$' not in salt
359 378
         hash = hashlib.sha1(force_bytes(salt + password)).hexdigest()
360 379
         return "%s$%s$%s" % (self.algorithm, salt, hash)
361 380
 
  381
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
362 382
     def verify(self, password, encoded):
363 383
         algorithm, salt, hash = encoded.split('$', 2)
364 384
         assert algorithm == self.algorithm
@@ -381,12 +401,14 @@ class MD5PasswordHasher(BasePasswordHasher):
381 401
     """
382 402
     algorithm = "md5"
383 403
 
  404
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
384 405
     def encode(self, password, salt):
385 406
         assert password is not None
386 407
         assert salt and '$' not in salt
387 408
         hash = hashlib.md5(force_bytes(salt + password)).hexdigest()
388 409
         return "%s$%s$%s" % (self.algorithm, salt, hash)
389 410
 
  411
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
390 412
     def verify(self, password, encoded):
391 413
         algorithm, salt, hash = encoded.split('$', 2)
392 414
         assert algorithm == self.algorithm
@@ -417,11 +439,13 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
417 439
     def salt(self):
418 440
         return ''
419 441
 
  442
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
420 443
     def encode(self, password, salt):
421 444
         assert salt == ''
422 445
         hash = hashlib.sha1(force_bytes(password)).hexdigest()
423 446
         return 'sha1$$%s' % hash
424 447
 
  448
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
425 449
     def verify(self, password, encoded):
426 450
         encoded_2 = self.encode(password, '')
427 451
         return constant_time_compare(encoded, encoded_2)
@@ -451,10 +475,12 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher):
451 475
     def salt(self):
452 476
         return ''
453 477
 
  478
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
454 479
     def encode(self, password, salt):
455 480
         assert salt == ''
456 481
         return hashlib.md5(force_bytes(password)).hexdigest()
457 482
 
  483
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
458 484
     def verify(self, password, encoded):
459 485
         if len(encoded) == 37 and encoded.startswith('md5$$'):
460 486
             encoded = encoded[5:]
@@ -480,6 +506,7 @@ class CryptPasswordHasher(BasePasswordHasher):
480 506
     def salt(self):
481 507
         return get_random_string(2)
482 508
 
  509
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
483 510
     def encode(self, password, salt):
484 511
         crypt = self._load_library()
485 512
         assert len(salt) == 2
@@ -487,6 +514,7 @@ def encode(self, password, salt):
487 514
         # we don't need to store the salt, but Django used to do this
488 515
         return "%s$%s$%s" % (self.algorithm, '', data)
489 516
 
  517
+    @password_max_length(MAXIMUM_PASSWORD_LENGTH)
490 518
     def verify(self, password, encoded):
491 519
         crypt = self._load_library()
492 520
         algorithm, salt, data = encoded.split('$', 2)
@@ -501,4 +529,3 @@ def safe_summary(self, encoded):
501 529
             (_('salt'), salt),
502 530
             (_('hash'), mask_hash(data, show=3)),
503 531
         ])
504  
-
85  django/contrib/auth/tests/test_hashers.py
@@ -2,9 +2,12 @@
2 2
 from __future__ import unicode_literals
3 3
 
4 4
 from django.conf.global_settings import PASSWORD_HASHERS as default_hashers
5  
-from django.contrib.auth.hashers import (is_password_usable, BasePasswordHasher,
6  
-    check_password, make_password, PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher,
7  
-    get_hasher, identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH)
  5
+from django.contrib.auth.hashers import (
  6
+    is_password_usable, BasePasswordHasher, check_password, make_password,
  7
+    PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher, get_hasher,
  8
+    identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH,
  9
+    MAXIMUM_PASSWORD_LENGTH, password_max_length
  10
+)
8 11
 from django.utils import six
9 12
 from django.utils import unittest
10 13
 from django.utils.unittest import skipUnless
@@ -38,6 +41,12 @@ def test_simple(self):
38 41
         self.assertTrue(is_password_usable(blank_encoded))
39 42
         self.assertTrue(check_password('', blank_encoded))
40 43
         self.assertFalse(check_password(' ', blank_encoded))
  44
+        # Long password
  45
+        self.assertRaises(
  46
+            ValueError,
  47
+            make_password,
  48
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  49
+        )
41 50
 
42 51
     def test_pkbdf2(self):
43 52
         encoded = make_password('lètmein', 'seasalt', 'pbkdf2_sha256')
@@ -53,6 +62,14 @@ def test_pkbdf2(self):
53 62
         self.assertTrue(is_password_usable(blank_encoded))
54 63
         self.assertTrue(check_password('', blank_encoded))
55 64
         self.assertFalse(check_password(' ', blank_encoded))
  65
+        # Long password
  66
+        self.assertRaises(
  67
+            ValueError,
  68
+            make_password,
  69
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  70
+            "seasalt",
  71
+            "pbkdf2_sha256",
  72
+        )
56 73
 
57 74
     def test_sha1(self):
58 75
         encoded = make_password('lètmein', 'seasalt', 'sha1')
@@ -68,6 +85,14 @@ def test_sha1(self):
68 85
         self.assertTrue(is_password_usable(blank_encoded))
69 86
         self.assertTrue(check_password('', blank_encoded))
70 87
         self.assertFalse(check_password(' ', blank_encoded))
  88
+        # Long password
  89
+        self.assertRaises(
  90
+            ValueError,
  91
+            make_password,
  92
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  93
+            "seasalt",
  94
+            "sha1",
  95
+        )
71 96
 
72 97
     def test_md5(self):
73 98
         encoded = make_password('lètmein', 'seasalt', 'md5')
@@ -83,6 +108,14 @@ def test_md5(self):
83 108
         self.assertTrue(is_password_usable(blank_encoded))
84 109
         self.assertTrue(check_password('', blank_encoded))
85 110
         self.assertFalse(check_password(' ', blank_encoded))
  111
+        # Long password
  112
+        self.assertRaises(
  113
+            ValueError,
  114
+            make_password,
  115
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  116
+            "seasalt",
  117
+            "md5",
  118
+        )
86 119
 
87 120
     def test_unsalted_md5(self):
88 121
         encoded = make_password('lètmein', '', 'unsalted_md5')
@@ -101,6 +134,14 @@ def test_unsalted_md5(self):
101 134
         self.assertTrue(is_password_usable(blank_encoded))
102 135
         self.assertTrue(check_password('', blank_encoded))
103 136
         self.assertFalse(check_password(' ', blank_encoded))
  137
+        # Long password
  138
+        self.assertRaises(
  139
+            ValueError,
  140
+            make_password,
  141
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  142
+            "",
  143
+            "unsalted_md5",
  144
+        )
104 145
 
105 146
     def test_unsalted_sha1(self):
106 147
         encoded = make_password('lètmein', '', 'unsalted_sha1')
@@ -118,6 +159,14 @@ def test_unsalted_sha1(self):
118 159
         self.assertTrue(is_password_usable(blank_encoded))
119 160
         self.assertTrue(check_password('', blank_encoded))
120 161
         self.assertFalse(check_password(' ', blank_encoded))
  162
+        # Long password
  163
+        self.assertRaises(
  164
+            ValueError,
  165
+            make_password,
  166
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  167
+            "",
  168
+            "unslated_sha1",
  169
+        )
121 170
 
122 171
     @skipUnless(crypt, "no crypt module to generate password.")
123 172
     def test_crypt(self):
@@ -133,6 +182,14 @@ def test_crypt(self):
133 182
         self.assertTrue(is_password_usable(blank_encoded))
134 183
         self.assertTrue(check_password('', blank_encoded))
135 184
         self.assertFalse(check_password(' ', blank_encoded))
  185
+        # Long password
  186
+        self.assertRaises(
  187
+            ValueError,
  188
+            make_password,
  189
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  190
+            "seasalt",
  191
+            "crypt",
  192
+        )
136 193
 
137 194
     @skipUnless(bcrypt, "bcrypt not installed")
138 195
     def test_bcrypt_sha256(self):
@@ -155,6 +212,13 @@ def test_bcrypt_sha256(self):
155 212
         self.assertTrue(is_password_usable(blank_encoded))
156 213
         self.assertTrue(check_password('', blank_encoded))
157 214
         self.assertFalse(check_password(' ', blank_encoded))
  215
+        # Long password
  216
+        self.assertRaises(
  217
+            ValueError,
  218
+            make_password,
  219
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  220
+            hasher="bcrypt_sha256",
  221
+        )
158 222
 
159 223
     @skipUnless(bcrypt, "bcrypt not installed")
160 224
     def test_bcrypt(self):
@@ -170,6 +234,13 @@ def test_bcrypt(self):
170 234
         self.assertTrue(is_password_usable(blank_encoded))
171 235
         self.assertTrue(check_password('', blank_encoded))
172 236
         self.assertFalse(check_password(' ', blank_encoded))
  237
+        # Long password
  238
+        self.assertRaises(
  239
+            ValueError,
  240
+            make_password,
  241
+            b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
  242
+            hasher="bcrypt",
  243
+        )
173 244
 
174 245
     def test_unusable(self):
175 246
         encoded = make_password(None)
@@ -202,6 +273,14 @@ def test_bad_encoded(self):
202 273
         self.assertFalse(is_password_usable('lètmein_badencoded'))
203 274
         self.assertFalse(is_password_usable(''))
204 275
 
  276
+    def test_max_password_length_decorator(self):
  277
+        @password_max_length(10)
  278
+        def encode(s, password, salt):
  279
+            return True
  280
+
  281
+        self.assertTrue(encode(None, b"1234", b"1234"))
  282
+        self.assertRaises(ValueError, encode, None, b"1234567890A", b"1234")
  283
+
205 284
     def test_low_level_pkbdf2(self):
206 285
         hasher = PBKDF2PasswordHasher()
207 286
         encoded = hasher.encode('lètmein', 'seasalt')
8  docs/releases/1.6.txt
@@ -869,6 +869,14 @@ Miscellaneous
869 869
   to prevent django from deleting the temporary .pot file it generates before
870 870
   creating the .po file.
871 871
 
  872
+* Passwords longer than 4096 bytes in length will no longer work and will
  873
+  instead raise a ``ValueError`` when using the hasher directory or the
  874
+  built in forms shipped with ``django.contrib.auth`` will fail validation.
  875
+
  876
+  The rationale behind this is a possibility of a Denial of Service attack when
  877
+  using a slow password hasher, such as the default PBKDF2, and sending very
  878
+  large passwords.
  879
+
872 880
 Features deprecated in 1.6
873 881
 ==========================
874 882
 

0 notes on commit 5ecc0f8

Please sign in to comment.
Something went wrong with that request. Please try again.