diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 72a9c5f504..23fee7d5b7 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -27,6 +27,12 @@ PASSWORD_RESET_TIMEOUT_DAYS_DEPRECATED_MSG = ( '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): """ @@ -195,6 +201,9 @@ class Settings: setattr(self, 'PASSWORD_RESET_TIMEOUT', self.PASSWORD_RESET_TIMEOUT_DAYS * 60 * 60 * 24) 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: # When we can, attempt to validate the timezone. If we can't find # this file, no check happens and it's harmless. @@ -241,6 +250,8 @@ class UserSettingsHolder: if name == 'PASSWORD_RESET_TIMEOUT_DAYS': setattr(self, 'PASSWORD_RESET_TIMEOUT', value * 60 * 60 * 24) 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) def __delattr__(self, name): diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index f0ffbd0560..f441c66bc8 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -436,6 +436,12 @@ WSGI_APPLICATION = None # you may be opening yourself up to a security risk. 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 # ############## diff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py index bb51cfbcc9..3a4a64ee19 100644 --- a/django/contrib/auth/base_user.py +++ b/django/contrib/auth/base_user.py @@ -4,6 +4,7 @@ not in INSTALLED_APPS. """ import unicodedata +from django.conf import settings from django.contrib.auth import password_validation from django.contrib.auth.hashers import ( check_password, is_password_usable, make_password, @@ -130,7 +131,14 @@ class AbstractBaseUser(models.Model): Return an HMAC of the password field. """ 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 def get_email_field_name(cls): diff --git a/django/contrib/auth/tokens.py b/django/contrib/auth/tokens.py index 21108ae652..0240370703 100644 --- a/django/contrib/auth/tokens.py +++ b/django/contrib/auth/tokens.py @@ -11,11 +11,14 @@ class PasswordResetTokenGenerator: reset mechanism. """ key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator" - algorithm = 'sha256' + algorithm = None secret = None def __init__(self): 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): """ diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py index 38b2c786b9..d96c318add 100644 --- a/django/core/checks/security/base.py +++ b/django/core/checks/security/base.py @@ -116,6 +116,11 @@ E023 = Error( id='security.E023', ) +E100 = Error( + "DEFAULT_HASHING_ALGORITHM must be 'sha1' or 'sha256'.", + id='security.E100', +) + def _security_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: return [E023] 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 [] diff --git a/django/core/signing.py b/django/core/signing.py index 652694bb99..c6713c3033 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -147,7 +147,7 @@ class Signer: # RemovedInDjango40Warning. 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.sep = sep if _SEP_UNSAFE.match(self.sep): @@ -156,7 +156,9 @@ class Signer: 'only A-z0-9-_=)' % sep, ) 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): return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index aa35943960..1f1364897e 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -118,6 +118,8 @@ details on these changes. * The ``{% ifequal %}`` and ``{% ifnotequal %}`` template tags will be removed. +* The ``DEFAULT_HASHING_ALGORITHM`` transitional setting will be removed. + .. _deprecation-removed-in-3.1: 3.1 diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 2f50b2152a..0e1ee50b46 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -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 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 ------- diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index e568774980..bf2d1ed6b0 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -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` 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 ` 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 ``DEFAULT_INDEX_TABLESPACE`` diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 59980ef310..3b0080b56f 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -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 of this release `. +.. _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 -------------- @@ -794,6 +815,8 @@ Miscellaneous ` option in :setting:`OPTIONS `. +* ``DEFAULT_HASHING_ALGORITHM`` transitional setting is deprecated. + .. _removed-features-3.1: Features removed in 3.1 diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt index b015f4bd87..d7d8f42728 100644 --- a/docs/topics/signing.txt +++ b/docs/topics/signing.txt @@ -81,13 +81,13 @@ generate signatures. You can use a different secret by passing it to the >>> value '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 separate values. ``sep`` cannot be in the :rfc:`URL safe base64 alphabet <4648#section-5>`. This alphabet contains alphanumeric characters, hyphens, and underscores. ``algorithm`` must be an algorithm supported by - :py:mod:`hashlib`. + :py:mod:`hashlib`, it defaults to ``'sha256'``. .. versionchanged:: 3.1 diff --git a/tests/auth_tests/test_middleware.py b/tests/auth_tests/test_middleware.py index fb23ed9fba..b6151acb19 100644 --- a/tests/auth_tests/test_middleware.py +++ b/tests/auth_tests/test_middleware.py @@ -2,7 +2,9 @@ from django.contrib.auth import HASH_SESSION_KEY from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.models import User 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): @@ -32,6 +34,12 @@ class TestAuthenticationMiddleware(TestCase): self.assertIsNotNone(self.request.user) 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): # After password change, user should be anonymous self.user.set_password('new_password') diff --git a/tests/auth_tests/test_password_reset_timeout_days.py b/tests/auth_tests/test_password_reset_timeout_days.py index 4bd5410f12..17aba80567 100644 --- a/tests/auth_tests/test_password_reset_timeout_days.py +++ b/tests/auth_tests/test_password_reset_timeout_days.py @@ -23,6 +23,7 @@ class DeprecationTests(TestCase): class Mocked(PasswordResetTokenGenerator): def __init__(self, now): self._now_val = now + super().__init__() def _now(self): return self._now_val diff --git a/tests/auth_tests/test_tokens.py b/tests/auth_tests/test_tokens.py index eaff78bd57..bba435be84 100644 --- a/tests/auth_tests/test_tokens.py +++ b/tests/auth_tests/test_tokens.py @@ -4,11 +4,14 @@ from django.conf import settings from django.contrib.auth.models import User from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.test import TestCase +from django.test.utils import ignore_warnings +from django.utils.deprecation import RemovedInDjango40Warning class MockedPasswordResetTokenGenerator(PasswordResetTokenGenerator): def __init__(self, now): self._now_val = now + super().__init__() def _now(self): return self._now_val @@ -88,6 +91,15 @@ class TokenGeneratorTest(TestCase): self.assertIs(p0.check_token(user, tk1), 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): # RemovedInDjango40Warning: pre-Django 3.1 tokens will be invalid. user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw') diff --git a/tests/deprecation/test_default_hashing_algorithm.py b/tests/deprecation/test_default_hashing_algorithm.py new file mode 100644 index 0000000000..078449ce4e --- /dev/null +++ b/tests/deprecation/test_default_hashing_algorithm.py @@ -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), []) diff --git a/tests/messages_tests/test_cookie.py b/tests/messages_tests/test_cookie.py index f1428fdf32..5d5fb42d67 100644 --- a/tests/messages_tests/test_cookie.py +++ b/tests/messages_tests/test_cookie.py @@ -7,6 +7,8 @@ from django.contrib.messages.storage.cookie import ( CookieStorage, MessageDecoder, MessageEncoder, ) 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 .base import BaseTests @@ -169,3 +171,14 @@ class CookieTests(BaseTests, SimpleTestCase): encoded_messages = '%s$%s' % (storage._legacy_hash(value), value) decoded_messages = storage._decode(encoded_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)) diff --git a/tests/signing/tests.py b/tests/signing/tests.py index df7cad9747..835ca4d6b2 100644 --- a/tests/signing/tests.py +++ b/tests/signing/tests.py @@ -2,8 +2,9 @@ import datetime from django.core import signing 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.deprecation import RemovedInDjango40Warning class TestSigner(SimpleTestCase): @@ -52,6 +53,14 @@ class TestSigner(SimpleTestCase): '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): signer = signing.Signer('predictable-secret', algorithm='whatever') msg = "'whatever' is not an algorithm accepted by the hashlib module." @@ -134,6 +143,13 @@ class TestSigner(SimpleTestCase): signed = 'ImEgc3RyaW5nIFx1MjAyMCI:1k1beT:ZfNhN1kdws7KosUleOvuYroPHEc' 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): "loads should raise exception for tampered objects" transforms = (