Fixed #31842 -- Added DEFAULT_HASHING_ALGORITHM transitional setting.

It's a transitional setting helpful in migrating multiple instance of
the same project to Django 3.1+.

Thanks Markus Holtermann for the report and review, Florian
Apolloner for the implementation idea and review, and Carlton Gibson
for the review.
This commit is contained in:
Mariusz Felisiak 2020-07-31 20:56:33 +02:00
parent bce4a53670
commit d907371ef9
17 changed files with 208 additions and 8 deletions

View File

@ -27,6 +27,12 @@ PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG = (
'PASSWORD_RESET_TIMEOUT instead.' 'PASSWORD_RESET_TIMEOUT instead.'
) )
DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG = (
'The DEFAULT_HASHING_ALGORITHM transitional setting is deprecated. '
'Support for it and tokens, cookies, sessions, and signatures that use '
'SHA-1 hashing algorithm will be removed in Django 4.0.'
)
class SettingsReference(str): class SettingsReference(str):
""" """
@ -195,6 +201,9 @@ class Settings:
setattr(self, 'PASSWORD_RESET_TIMEOUT', self.PASSWORD_RESET_TIMEOUT_DAYS * 60 * 60 * 24) setattr(self, 'PASSWORD_RESET_TIMEOUT', self.PASSWORD_RESET_TIMEOUT_DAYS * 60 * 60 * 24)
warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning) warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning)
if self.is_overridden('DEFAULT_HASHING_ALGORITHM'):
warnings.warn(DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, RemovedInDjango40Warning)
if hasattr(time, 'tzset') and self.TIME_ZONE: if hasattr(time, 'tzset') and self.TIME_ZONE:
# When we can, attempt to validate the timezone. If we can't find # When we can, attempt to validate the timezone. If we can't find
# this file, no check happens and it's harmless. # this file, no check happens and it's harmless.
@ -241,6 +250,8 @@ class UserSettingsHolder:
if name == 'PASSWORD_RESET_TIMEOUT_DAYS': if name == 'PASSWORD_RESET_TIMEOUT_DAYS':
setattr(self, 'PASSWORD_RESET_TIMEOUT', value * 60 * 60 * 24) setattr(self, 'PASSWORD_RESET_TIMEOUT', value * 60 * 60 * 24)
warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning) warnings.warn(PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG, RemovedInDjango40Warning)
if name == 'DEFAULT_HASHING_ALGORITHM':
warnings.warn(DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, RemovedInDjango40Warning)
super().__setattr__(name, value) super().__setattr__(name, value)
def __delattr__(self, name): def __delattr__(self, name):

View File

@ -436,6 +436,12 @@ WSGI_APPLICATION = None
# you may be opening yourself up to a security risk. # you may be opening yourself up to a security risk.
SECURE_PROXY_SSL_HEADER = None SECURE_PROXY_SSL_HEADER = None
# Default hashing algorithm to use for encoding cookies, password reset tokens
# in the admin site, user sessions, and signatures. It's a transitional setting
# helpful in migrating multiple instance of the same project to Django 3.1+.
# Algorithm must be 'sha1' or 'sha256'.
DEFAULT_HASHING_ALGORITHM = 'sha256'
############## ##############
# MIDDLEWARE # # MIDDLEWARE #
############## ##############

View File

@ -4,6 +4,7 @@ not in INSTALLED_APPS.
""" """
import unicodedata import unicodedata
from django.conf import settings
from django.contrib.auth import password_validation from django.contrib.auth import password_validation
from django.contrib.auth.hashers import ( from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password, check_password, is_password_usable, make_password,
@ -130,7 +131,14 @@ class AbstractBaseUser(models.Model):
Return an HMAC of the password field. Return an HMAC of the password field.
""" """
key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash" key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
return salted_hmac(key_salt, self.password, algorithm='sha256').hexdigest() return salted_hmac(
key_salt,
self.password,
# RemovedInDjango40Warning: when the deprecation ends, replace
# with:
# algorithm='sha256',
algorithm=settings.DEFAULT_HASHING_ALGORITHM,
).hexdigest()
@classmethod @classmethod
def get_email_field_name(cls): def get_email_field_name(cls):

View File

@ -11,11 +11,14 @@ class PasswordResetTokenGenerator:
reset mechanism. reset mechanism.
""" """
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator" key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
algorithm = 'sha256' algorithm = None
secret = None secret = None
def __init__(self): def __init__(self):
self.secret = self.secret or settings.SECRET_KEY self.secret = self.secret or settings.SECRET_KEY
# RemovedInDjango40Warning: when the deprecation ends, replace with:
# self.algorithm = self.algorithm or 'sha256'
self.algorithm = self.algorithm or settings.DEFAULT_HASHING_ALGORITHM
def make_token(self, user): def make_token(self, user):
""" """

View File

@ -116,6 +116,11 @@ E023 = Error(
id='security.E023', id='security.E023',
) )
E100 = Error(
"DEFAULT_HASHING_ALGORITHM must be 'sha1' or 'sha256'.",
id='security.E100',
)
def _security_middleware(): def _security_middleware():
return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
@ -228,3 +233,11 @@ def check_referrer_policy(app_configs, **kwargs):
if not values <= REFERRER_POLICY_VALUES: if not values <= REFERRER_POLICY_VALUES:
return [E023] return [E023]
return [] return []
# RemovedInDjango40Warning
@register(Tags.security)
def check_default_hashing_algorithm(app_configs, **kwargs):
if settings.DEFAULT_HASHING_ALGORITHM not in {'sha1', 'sha256'}:
return [E100]
return []

View File

@ -147,7 +147,7 @@ class Signer:
# RemovedInDjango40Warning. # RemovedInDjango40Warning.
legacy_algorithm = 'sha1' legacy_algorithm = 'sha1'
def __init__(self, key=None, sep=':', salt=None, algorithm='sha256'): def __init__(self, key=None, sep=':', salt=None, algorithm=None):
self.key = key or settings.SECRET_KEY self.key = key or settings.SECRET_KEY
self.sep = sep self.sep = sep
if _SEP_UNSAFE.match(self.sep): if _SEP_UNSAFE.match(self.sep):
@ -156,7 +156,9 @@ class Signer:
'only A-z0-9-_=)' % sep, 'only A-z0-9-_=)' % sep,
) )
self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__) self.salt = salt or '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
self.algorithm = algorithm # RemovedInDjango40Warning: when the deprecation ends, replace with:
# self.algorithm = algorithm or 'sha256'
self.algorithm = algorithm or settings.DEFAULT_HASHING_ALGORITHM
def signature(self, value): def signature(self, value):
return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm) return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm)

View File

@ -118,6 +118,8 @@ details on these changes.
* The ``{% ifequal %}`` and ``{% ifnotequal %}`` template tags will be removed. * The ``{% ifequal %}`` and ``{% ifnotequal %}`` template tags will be removed.
* The ``DEFAULT_HASHING_ALGORITHM`` transitional setting will be removed.
.. _deprecation-removed-in-3.1: .. _deprecation-removed-in-3.1:
3.1 3.1

View File

@ -484,6 +484,12 @@ The following checks are run if you use the :option:`check --deploy` option:
* **security.E023**: You have set the :setting:`SECURE_REFERRER_POLICY` setting * **security.E023**: You have set the :setting:`SECURE_REFERRER_POLICY` setting
to an invalid value. to an invalid value.
The following checks verify that your security-related settings are correctly
configured:
* **security.E100**: :setting:`DEFAULT_HASHING_ALGORITHM` must be ``'sha1'`` or
``'sha256'``.
Signals Signals
------- -------

View File

@ -1295,6 +1295,27 @@ Default email address to use for various automated correspondence from the
site manager(s). This doesn't include error messages sent to :setting:`ADMINS` site manager(s). This doesn't include error messages sent to :setting:`ADMINS`
and :setting:`MANAGERS`; for that, see :setting:`SERVER_EMAIL`. and :setting:`MANAGERS`; for that, see :setting:`SERVER_EMAIL`.
.. setting:: DEFAULT_HASHING_ALGORITHM
``DEFAULT_HASHING_ALGORITHM``
-----------------------------
.. versionadded:: 3.1
Default: ``'sha256'``
Default hashing algorithm to use for encoding cookies, password reset tokens in
the admin site, user sessions, and signatures created by
:class:`django.core.signing.Signer` and :meth:`django.core.signing.dumps`.
Algorithm must be ``'sha1'`` or ``'sha256'``. See
:ref:`release notes <default-hashing-algorithm-usage>` for usage details.
.. deprecated:: 3.1
This transitional setting is deprecated. Support for it and tokens,
cookies, sessions, and signatures that use SHA-1 hashing algorithm will be
removed in Django 4.0.
.. setting:: DEFAULT_INDEX_TABLESPACE .. setting:: DEFAULT_INDEX_TABLESPACE
``DEFAULT_INDEX_TABLESPACE`` ``DEFAULT_INDEX_TABLESPACE``

View File

@ -96,6 +96,27 @@ and generate and apply a database migration. For now, the old fields and
transforms are left as a reference to the new ones and are :ref:`deprecated as transforms are left as a reference to the new ones and are :ref:`deprecated as
of this release <deprecated-jsonfield>`. of this release <deprecated-jsonfield>`.
.. _default-hashing-algorithm-usage:
``DEFAULT_HASHING_ALGORITHM`` settings
--------------------------------------
The new :setting:`DEFAULT_HASHING_ALGORITHM` transitional setting allows
specifying the default hashing algorithm to use for encoding cookies, password
reset tokens in the admin site, user sessions, and signatures created by
:class:`django.core.signing.Signer` and :meth:`django.core.signing.dumps`.
Support for SHA-256 was added in Django 3.1. If you are upgrading multiple
instances of the same project to Django 3.1, you should set
:setting:`DEFAULT_HASHING_ALGORITHM` to ``'sha1'`` during the transition, in
order to allow compatibility with the older versions of Django. Once the
transition to 3.1 is complete you can stop overriding
:setting:`DEFAULT_HASHING_ALGORITHM`.
This setting is deprecated as of this release, because support for tokens,
cookies, sessions, and signatures that use SHA-1 algorithm will be removed in
Django 4.0.
Minor features Minor features
-------------- --------------
@ -794,6 +815,8 @@ Miscellaneous
<django.template.backends.django.DjangoTemplates>` option in <django.template.backends.django.DjangoTemplates>` option in
:setting:`OPTIONS <TEMPLATES-OPTIONS>`. :setting:`OPTIONS <TEMPLATES-OPTIONS>`.
* ``DEFAULT_HASHING_ALGORITHM`` transitional setting is deprecated.
.. _removed-features-3.1: .. _removed-features-3.1:
Features removed in 3.1 Features removed in 3.1

View File

@ -81,13 +81,13 @@ generate signatures. You can use a different secret by passing it to the
>>> value >>> value
'My string:EkfQJafvGyiofrdGnuthdxImIJw' 'My string:EkfQJafvGyiofrdGnuthdxImIJw'
.. class:: Signer(key=None, sep=':', salt=None, algorithm='sha256') .. class:: Signer(key=None, sep=':', salt=None, algorithm=None)
Returns a signer which uses ``key`` to generate signatures and ``sep`` to Returns a signer which uses ``key`` to generate signatures and ``sep`` to
separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet
<4648#section-5>`. This alphabet contains alphanumeric characters, hyphens, <4648#section-5>`. This alphabet contains alphanumeric characters, hyphens,
and underscores. ``algorithm`` must be an algorithm supported by and underscores. ``algorithm`` must be an algorithm supported by
:py:mod:`hashlib`. :py:mod:`hashlib`, it defaults to ``'sha256'``.
.. versionchanged:: 3.1 .. versionchanged:: 3.1

View File

@ -2,7 +2,9 @@ from django.contrib.auth import HASH_SESSION_KEY
from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.middleware import AuthenticationMiddleware
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.test import TestCase from django.test import TestCase, override_settings
from django.test.utils import ignore_warnings
from django.utils.deprecation import RemovedInDjango40Warning
class TestAuthenticationMiddleware(TestCase): class TestAuthenticationMiddleware(TestCase):
@ -32,6 +34,12 @@ class TestAuthenticationMiddleware(TestCase):
self.assertIsNotNone(self.request.user) self.assertIsNotNone(self.request.user)
self.assertFalse(self.request.user.is_anonymous) self.assertFalse(self.request.user.is_anonymous)
@ignore_warnings(category=RemovedInDjango40Warning)
def test_session_default_hashing_algorithm(self):
hash_session = self.client.session[HASH_SESSION_KEY]
with override_settings(DEFAULT_HASHING_ALGORITHM='sha1'):
self.assertNotEqual(hash_session, self.user.get_session_auth_hash())
def test_changed_password_invalidates_session(self): def test_changed_password_invalidates_session(self):
# After password change, user should be anonymous # After password change, user should be anonymous
self.user.set_password('new_password') self.user.set_password('new_password')

View File

@ -23,6 +23,7 @@ class DeprecationTests(TestCase):
class Mocked(PasswordResetTokenGenerator): class Mocked(PasswordResetTokenGenerator):
def __init__(self, now): def __init__(self, now):
self._now_val = now self._now_val = now
super().__init__()
def _now(self): def _now(self):
return self._now_val return self._now_val

View File

@ -4,11 +4,14 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.test import TestCase from django.test import TestCase
from django.test.utils import ignore_warnings
from django.utils.deprecation import RemovedInDjango40Warning
class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator): class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator):
def __init__(self, now): def __init__(self, now):
self._now_val = now self._now_val = now
super().__init__()
def _now(self): def _now(self):
return self._now_val return self._now_val
@ -88,6 +91,15 @@ class TokenGeneratorTest(TestCase):
self.assertIs(p0.check_token(user, tk1), False) self.assertIs(p0.check_token(user, tk1), False)
self.assertIs(p1.check_token(user, tk0), False) self.assertIs(p1.check_token(user, tk0), False)
@ignore_warnings(category=RemovedInDjango40Warning)
def test_token_default_hashing_algorithm(self):
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
generator = PasswordResetTokenGenerator()
self.assertEqual(generator.algorithm, 'sha1')
token = generator.make_token(user)
self.assertIs(generator.check_token(user, token), True)
def test_legacy_token_validation(self): def test_legacy_token_validation(self):
# RemovedInDjango40Warning: pre-Django 3.1 tokens will be invalid. # RemovedInDjango40Warning: pre-Django 3.1 tokens will be invalid.
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw') user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')

View File

@ -0,0 +1,55 @@
import sys
from types import ModuleType
from django.conf import (
DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG, Settings, settings,
)
from django.core.checks.security import base as security_base
from django.test import TestCase, ignore_warnings
from django.utils.deprecation import RemovedInDjango40Warning
class DefaultHashingAlgorithmDeprecationTests(TestCase):
msg = DEFAULT_HASHING_ALGORITHM_DEPRECATED_MSG
def test_override_settings_warning(self):
with self.assertRaisesMessage(RemovedInDjango40Warning, self.msg):
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
pass
def test_settings_init_warning(self):
settings_module = ModuleType('fake_settings_module')
settings_module.SECRET_KEY = 'foo'
settings_module.DEFAULT_HASHING_ALGORITHM = 'sha1'
sys.modules['fake_settings_module'] = settings_module
try:
with self.assertRaisesMessage(RemovedInDjango40Warning, self.msg):
Settings('fake_settings_module')
finally:
del sys.modules['fake_settings_module']
def test_access(self):
# Warning is not raised on access.
self.assertEqual(settings.DEFAULT_HASHING_ALGORITHM, 'sha256')
@ignore_warnings(category=RemovedInDjango40Warning)
def test_system_check_invalid_value(self):
tests = [
None,
256,
'invalid',
'md5',
'sha512',
]
for value in tests:
with self.subTest(value=value), self.settings(DEFAULT_HASHING_ALGORITHM=value):
self.assertEqual(
security_base.check_default_hashing_algorithm(None),
[security_base.E100],
)
@ignore_warnings(category=RemovedInDjango40Warning)
def test_system_check_valid_value(self):
for value in ['sha1', 'sha256']:
with self.subTest(value=value), self.settings(DEFAULT_HASHING_ALGORITHM=value):
self.assertEqual(security_base.check_default_hashing_algorithm(None), [])

View File

@ -7,6 +7,8 @@ from django.contrib.messages.storage.cookie import (
CookieStorage, MessageDecoder, MessageEncoder, CookieStorage, MessageDecoder, MessageEncoder,
) )
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.test.utils import ignore_warnings
from django.utils.deprecation import RemovedInDjango40Warning
from django.utils.safestring import SafeData, mark_safe from django.utils.safestring import SafeData, mark_safe
from .base import BaseTests from .base import BaseTests
@ -169,3 +171,14 @@ class CookieTests(BaseTests, SimpleTestCase):
encoded_messages = '%s$%s' % (storage._legacy_hash(value), value) encoded_messages = '%s$%s' % (storage._legacy_hash(value), value)
decoded_messages = storage._decode(encoded_messages) decoded_messages = storage._decode(encoded_messages)
self.assertEqual(messages, decoded_messages) self.assertEqual(messages, decoded_messages)
@ignore_warnings(category=RemovedInDjango40Warning)
def test_default_hashing_algorithm(self):
messages = Message(constants.DEBUG, ['this', 'that'])
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
storage = self.get_storage()
encoded = storage._encode(messages)
decoded = storage._decode(encoded)
self.assertEqual(decoded, messages)
storage_default = self.get_storage()
self.assertNotEqual(encoded, storage_default._encode(messages))

View File

@ -2,8 +2,9 @@ import datetime
from django.core import signing from django.core import signing
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import freeze_time from django.test.utils import freeze_time, ignore_warnings
from django.utils.crypto import InvalidAlgorithm from django.utils.crypto import InvalidAlgorithm
from django.utils.deprecation import RemovedInDjango40Warning
class TestSigner(SimpleTestCase): class TestSigner(SimpleTestCase):
@ -52,6 +53,14 @@ class TestSigner(SimpleTestCase):
'VzO9_jVu7R-VkqknHYNvw', 'VzO9_jVu7R-VkqknHYNvw',
) )
@ignore_warnings(category=RemovedInDjango40Warning)
def test_default_hashing_algorithm(self):
signer = signing.Signer('predictable-secret', algorithm='sha1')
signature_sha1 = signer.signature('hello')
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
signer = signing.Signer('predictable-secret')
self.assertEqual(signer.signature('hello'), signature_sha1)
def test_invalid_algorithm(self): def test_invalid_algorithm(self):
signer = signing.Signer('predictable-secret', algorithm='whatever') signer = signing.Signer('predictable-secret', algorithm='whatever')
msg = "'whatever' is not an algorithm accepted by the hashlib module." msg = "'whatever' is not an algorithm accepted by the hashlib module."
@ -134,6 +143,13 @@ class TestSigner(SimpleTestCase):
signed = 'ImEgc3RyaW5nIFx1MjAyMCI:1k1beT:ZfNhN1kdws7KosUleOvuYroPHEc' signed = 'ImEgc3RyaW5nIFx1MjAyMCI:1k1beT:ZfNhN1kdws7KosUleOvuYroPHEc'
self.assertEqual(signing.loads(signed), value) self.assertEqual(signing.loads(signed), value)
@ignore_warnings(category=RemovedInDjango40Warning)
def test_dumps_loads_default_hashing_algorithm_sha1(self):
value = 'a string \u2020'
with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
signed = signing.dumps(value)
self.assertEqual(signing.loads(signed), value)
def test_decode_detects_tampering(self): def test_decode_detects_tampering(self):
"loads should raise exception for tampered objects" "loads should raise exception for tampered objects"
transforms = ( transforms = (