[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 aae5a96d57 from master.
This commit is contained in:
Russell Keith-Magee 2013-09-15 13:46:16 +08:00
parent 4c4954a3c1
commit 5ecc0f828e
4 changed files with 153 additions and 17 deletions

View File

@ -15,7 +15,9 @@ from django.utils.translation import ugettext, ugettext_lazy as _
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher from django.contrib.auth.hashers import (
MAXIMUM_PASSWORD_LENGTH, UNUSABLE_PASSWORD_PREFIX, identify_hasher,
)
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.models import get_current_site from django.contrib.sites.models import get_current_site
@ -81,9 +83,10 @@ class UserCreationForm(forms.ModelForm):
'invalid': _("This value may contain only letters, numbers and " 'invalid': _("This value may contain only letters, numbers and "
"@/./+/-/_ characters.")}) "@/./+/-/_ characters.")})
password1 = forms.CharField(label=_("Password"), password1 = forms.CharField(label=_("Password"),
widget=forms.PasswordInput) widget=forms.PasswordInput, max_length=MAXIMUM_PASSWORD_LENGTH)
password2 = forms.CharField(label=_("Password confirmation"), password2 = forms.CharField(label=_("Password confirmation"),
widget=forms.PasswordInput, widget=forms.PasswordInput,
max_length=MAXIMUM_PASSWORD_LENGTH,
help_text=_("Enter the same password as above, for verification.")) help_text=_("Enter the same password as above, for verification."))
class Meta: class Meta:
@ -157,7 +160,11 @@ class AuthenticationForm(forms.Form):
username/password logins. username/password logins.
""" """
username = forms.CharField(max_length=254) username = forms.CharField(max_length=254)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) password = forms.CharField(
label=_("Password"),
widget=forms.PasswordInput,
max_length=MAXIMUM_PASSWORD_LENGTH,
)
error_messages = { error_messages = {
'invalid_login': _("Please enter a correct %(username)s and password. " 'invalid_login': _("Please enter a correct %(username)s and password. "
@ -264,10 +271,16 @@ class SetPasswordForm(forms.Form):
error_messages = { error_messages = {
'password_mismatch': _("The two password fields didn't match."), 'password_mismatch': _("The two password fields didn't match."),
} }
new_password1 = forms.CharField(label=_("New password"), new_password1 = forms.CharField(
widget=forms.PasswordInput) label=_("New password"),
new_password2 = forms.CharField(label=_("New password confirmation"), widget=forms.PasswordInput,
widget=forms.PasswordInput) max_length=MAXIMUM_PASSWORD_LENGTH,
)
new_password2 = forms.CharField(
label=_("New password confirmation"),
widget=forms.PasswordInput,
max_length=MAXIMUM_PASSWORD_LENGTH,
)
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user
@ -300,8 +313,11 @@ class PasswordChangeForm(SetPasswordForm):
'password_incorrect': _("Your old password was entered incorrectly. " 'password_incorrect': _("Your old password was entered incorrectly. "
"Please enter it again."), "Please enter it again."),
}) })
old_password = forms.CharField(label=_("Old password"), old_password = forms.CharField(
widget=forms.PasswordInput) label=_("Old password"),
widget=forms.PasswordInput,
max_length=MAXIMUM_PASSWORD_LENGTH,
)
def clean_old_password(self): def clean_old_password(self):
""" """
@ -328,10 +344,16 @@ class AdminPasswordChangeForm(forms.Form):
error_messages = { error_messages = {
'password_mismatch': _("The two password fields didn't match."), 'password_mismatch': _("The two password fields didn't match."),
} }
password1 = forms.CharField(label=_("Password"), password1 = forms.CharField(
widget=forms.PasswordInput) label=_("Password"),
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput,
widget=forms.PasswordInput) max_length=MAXIMUM_PASSWORD_LENGTH,
)
password2 = forms.CharField(
label=_("Password (again)"),
widget=forms.PasswordInput,
max_length=MAXIMUM_PASSWORD_LENGTH,
)
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import base64 import base64
import binascii import binascii
import functools
import hashlib import hashlib
from django.dispatch import receiver from django.dispatch import receiver
@ -19,6 +20,7 @@ from django.utils.translation import ugettext_noop as _
UNUSABLE_PASSWORD_PREFIX = '!' # This will never be a valid encoded hash UNUSABLE_PASSWORD_PREFIX = '!' # This will never be a valid encoded hash
UNUSABLE_PASSWORD_SUFFIX_LENGTH = 40 # number of random chars to add after UNUSABLE_PASSWORD_PREFIX UNUSABLE_PASSWORD_SUFFIX_LENGTH = 40 # number of random chars to add after UNUSABLE_PASSWORD_PREFIX
MAXIMUM_PASSWORD_LENGTH = 4096 # The maximum length a password can be to prevent DoS
HASHERS = None # lazily loaded from PASSWORD_HASHERS HASHERS = None # lazily loaded from PASSWORD_HASHERS
PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS
@ -31,6 +33,18 @@ def reset_hashers(**kwargs):
PREFERRED_HASHER = None PREFERRED_HASHER = None
def password_max_length(max_length):
def inner(fn):
@functools.wraps(fn)
def wrapper(self, password, *args, **kwargs):
if len(password) > max_length:
raise ValueError("Invalid password; Must be less than or equal"
" to %d bytes" % max_length)
return fn(self, password, *args, **kwargs)
return wrapper
return inner
def is_password_usable(encoded): def is_password_usable(encoded):
if encoded is None or encoded.startswith(UNUSABLE_PASSWORD_PREFIX): if encoded is None or encoded.startswith(UNUSABLE_PASSWORD_PREFIX):
return False return False
@ -225,6 +239,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
iterations = 10000 iterations = 10000
digest = hashlib.sha256 digest = hashlib.sha256
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt, iterations=None): def encode(self, password, salt, iterations=None):
assert password is not None assert password is not None
assert salt and '$' not in salt assert salt and '$' not in salt
@ -234,6 +249,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
hash = base64.b64encode(hash).decode('ascii').strip() hash = base64.b64encode(hash).decode('ascii').strip()
return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
algorithm, iterations, salt, hash = encoded.split('$', 3) algorithm, iterations, salt, hash = encoded.split('$', 3)
assert algorithm == self.algorithm assert algorithm == self.algorithm
@ -280,6 +296,7 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
bcrypt = self._load_library() bcrypt = self._load_library()
return bcrypt.gensalt(self.rounds) return bcrypt.gensalt(self.rounds)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
bcrypt = self._load_library() bcrypt = self._load_library()
# Need to reevaluate the force_bytes call once bcrypt is supported on # Need to reevaluate the force_bytes call once bcrypt is supported on
@ -297,6 +314,7 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
data = bcrypt.hashpw(password, salt) data = bcrypt.hashpw(password, salt)
return "%s$%s" % (self.algorithm, force_text(data)) return "%s$%s" % (self.algorithm, force_text(data))
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
algorithm, data = encoded.split('$', 1) algorithm, data = encoded.split('$', 1)
assert algorithm == self.algorithm assert algorithm == self.algorithm
@ -353,12 +371,14 @@ class SHA1PasswordHasher(BasePasswordHasher):
""" """
algorithm = "sha1" algorithm = "sha1"
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
assert password is not None assert password is not None
assert salt and '$' not in salt assert salt and '$' not in salt
hash = hashlib.sha1(force_bytes(salt + password)).hexdigest() hash = hashlib.sha1(force_bytes(salt + password)).hexdigest()
return "%s$%s$%s" % (self.algorithm, salt, hash) return "%s$%s$%s" % (self.algorithm, salt, hash)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
algorithm, salt, hash = encoded.split('$', 2) algorithm, salt, hash = encoded.split('$', 2)
assert algorithm == self.algorithm assert algorithm == self.algorithm
@ -381,12 +401,14 @@ class MD5PasswordHasher(BasePasswordHasher):
""" """
algorithm = "md5" algorithm = "md5"
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
assert password is not None assert password is not None
assert salt and '$' not in salt assert salt and '$' not in salt
hash = hashlib.md5(force_bytes(salt + password)).hexdigest() hash = hashlib.md5(force_bytes(salt + password)).hexdigest()
return "%s$%s$%s" % (self.algorithm, salt, hash) return "%s$%s$%s" % (self.algorithm, salt, hash)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
algorithm, salt, hash = encoded.split('$', 2) algorithm, salt, hash = encoded.split('$', 2)
assert algorithm == self.algorithm assert algorithm == self.algorithm
@ -417,11 +439,13 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
def salt(self): def salt(self):
return '' return ''
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
assert salt == '' assert salt == ''
hash = hashlib.sha1(force_bytes(password)).hexdigest() hash = hashlib.sha1(force_bytes(password)).hexdigest()
return 'sha1$$%s' % hash return 'sha1$$%s' % hash
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
encoded_2 = self.encode(password, '') encoded_2 = self.encode(password, '')
return constant_time_compare(encoded, encoded_2) return constant_time_compare(encoded, encoded_2)
@ -451,10 +475,12 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher):
def salt(self): def salt(self):
return '' return ''
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
assert salt == '' assert salt == ''
return hashlib.md5(force_bytes(password)).hexdigest() return hashlib.md5(force_bytes(password)).hexdigest()
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
if len(encoded) == 37 and encoded.startswith('md5$$'): if len(encoded) == 37 and encoded.startswith('md5$$'):
encoded = encoded[5:] encoded = encoded[5:]
@ -480,6 +506,7 @@ class CryptPasswordHasher(BasePasswordHasher):
def salt(self): def salt(self):
return get_random_string(2) return get_random_string(2)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def encode(self, password, salt): def encode(self, password, salt):
crypt = self._load_library() crypt = self._load_library()
assert len(salt) == 2 assert len(salt) == 2
@ -487,6 +514,7 @@ class CryptPasswordHasher(BasePasswordHasher):
# we don't need to store the salt, but Django used to do this # we don't need to store the salt, but Django used to do this
return "%s$%s$%s" % (self.algorithm, '', data) return "%s$%s$%s" % (self.algorithm, '', data)
@password_max_length(MAXIMUM_PASSWORD_LENGTH)
def verify(self, password, encoded): def verify(self, password, encoded):
crypt = self._load_library() crypt = self._load_library()
algorithm, salt, data = encoded.split('$', 2) algorithm, salt, data = encoded.split('$', 2)
@ -501,4 +529,3 @@ class CryptPasswordHasher(BasePasswordHasher):
(_('salt'), salt), (_('salt'), salt),
(_('hash'), mask_hash(data, show=3)), (_('hash'), mask_hash(data, show=3)),
]) ])

View File

@ -2,9 +2,12 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf.global_settings import PASSWORD_HASHERS as default_hashers from django.conf.global_settings import PASSWORD_HASHERS as default_hashers
from django.contrib.auth.hashers import (is_password_usable, BasePasswordHasher, from django.contrib.auth.hashers import (
check_password, make_password, PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher, is_password_usable, BasePasswordHasher, check_password, make_password,
get_hasher, identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH) PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher, get_hasher,
identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH,
MAXIMUM_PASSWORD_LENGTH, password_max_length
)
from django.utils import six from django.utils import six
from django.utils import unittest from django.utils import unittest
from django.utils.unittest import skipUnless from django.utils.unittest import skipUnless
@ -38,6 +41,12 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
)
def test_pkbdf2(self): def test_pkbdf2(self):
encoded = make_password('lètmein', 'seasalt', 'pbkdf2_sha256') encoded = make_password('lètmein', 'seasalt', 'pbkdf2_sha256')
@ -53,6 +62,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"seasalt",
"pbkdf2_sha256",
)
def test_sha1(self): def test_sha1(self):
encoded = make_password('lètmein', 'seasalt', 'sha1') encoded = make_password('lètmein', 'seasalt', 'sha1')
@ -68,6 +85,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"seasalt",
"sha1",
)
def test_md5(self): def test_md5(self):
encoded = make_password('lètmein', 'seasalt', 'md5') encoded = make_password('lètmein', 'seasalt', 'md5')
@ -83,6 +108,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"seasalt",
"md5",
)
def test_unsalted_md5(self): def test_unsalted_md5(self):
encoded = make_password('lètmein', '', 'unsalted_md5') encoded = make_password('lètmein', '', 'unsalted_md5')
@ -101,6 +134,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"",
"unsalted_md5",
)
def test_unsalted_sha1(self): def test_unsalted_sha1(self):
encoded = make_password('lètmein', '', 'unsalted_sha1') encoded = make_password('lètmein', '', 'unsalted_sha1')
@ -118,6 +159,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"",
"unslated_sha1",
)
@skipUnless(crypt, "no crypt module to generate password.") @skipUnless(crypt, "no crypt module to generate password.")
def test_crypt(self): def test_crypt(self):
@ -133,6 +182,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
"seasalt",
"crypt",
)
@skipUnless(bcrypt, "bcrypt not installed") @skipUnless(bcrypt, "bcrypt not installed")
def test_bcrypt_sha256(self): def test_bcrypt_sha256(self):
@ -155,6 +212,13 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
hasher="bcrypt_sha256",
)
@skipUnless(bcrypt, "bcrypt not installed") @skipUnless(bcrypt, "bcrypt not installed")
def test_bcrypt(self): def test_bcrypt(self):
@ -170,6 +234,13 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertTrue(is_password_usable(blank_encoded)) self.assertTrue(is_password_usable(blank_encoded))
self.assertTrue(check_password('', blank_encoded)) self.assertTrue(check_password('', blank_encoded))
self.assertFalse(check_password(' ', blank_encoded)) self.assertFalse(check_password(' ', blank_encoded))
# Long password
self.assertRaises(
ValueError,
make_password,
b"1" * (MAXIMUM_PASSWORD_LENGTH + 1),
hasher="bcrypt",
)
def test_unusable(self): def test_unusable(self):
encoded = make_password(None) encoded = make_password(None)
@ -202,6 +273,14 @@ class TestUtilsHashPass(unittest.TestCase):
self.assertFalse(is_password_usable('lètmein_badencoded')) self.assertFalse(is_password_usable('lètmein_badencoded'))
self.assertFalse(is_password_usable('')) self.assertFalse(is_password_usable(''))
def test_max_password_length_decorator(self):
@password_max_length(10)
def encode(s, password, salt):
return True
self.assertTrue(encode(None, b"1234", b"1234"))
self.assertRaises(ValueError, encode, None, b"1234567890A", b"1234")
def test_low_level_pkbdf2(self): def test_low_level_pkbdf2(self):
hasher = PBKDF2PasswordHasher() hasher = PBKDF2PasswordHasher()
encoded = hasher.encode('lètmein', 'seasalt') encoded = hasher.encode('lètmein', 'seasalt')

View File

@ -869,6 +869,14 @@ Miscellaneous
to prevent django from deleting the temporary .pot file it generates before to prevent django from deleting the temporary .pot file it generates before
creating the .po file. creating the .po file.
* Passwords longer than 4096 bytes in length will no longer work and will
instead raise a ``ValueError`` when using the hasher directory or the
built in forms shipped with ``django.contrib.auth`` will fail validation.
The rationale behind this is a possibility of a Denial of Service attack when
using a slow password hasher, such as the default PBKDF2, and sending very
large passwords.
Features deprecated in 1.6 Features deprecated in 1.6
========================== ==========================