summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMariusz Felisiak <felisiak.mariusz@gmail.com>2020-07-31 20:56:33 +0200
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2020-08-04 09:39:29 +0200
commit985735265563d4bb0ff44cf6b823446e1813fa34 (patch)
tree868c216cb98ead051b9e56fcaf49c862a3382127
parentacb7866b1fdda7592d9a16f0d0cc98f5d86a0a56 (diff)
[3.1.x] 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. Backport of d907371ef99a1e4ca6bc1660f57d81f265750984 from master.
-rw-r--r--django/conf/__init__.py11
-rw-r--r--django/conf/global_settings.py6
-rw-r--r--django/contrib/auth/base_user.py10
-rw-r--r--django/contrib/auth/tokens.py7
-rw-r--r--django/core/checks/security/base.py13
-rw-r--r--django/core/signing.py6
-rw-r--r--docs/internals/deprecation.txt2
-rw-r--r--docs/ref/checks.txt6
-rw-r--r--docs/ref/settings.txt21
-rw-r--r--docs/releases/3.1.txt23
-rw-r--r--docs/topics/signing.txt4
-rw-r--r--tests/auth_tests/test_middleware.py10
-rw-r--r--tests/auth_tests/test_password_reset_timeout_days.py1
-rw-r--r--tests/auth_tests/test_tokens.py12
-rw-r--r--tests/deprecation/test_default_hashing_algorithm.py55
-rw-r--r--tests/messages_tests/test_cookie.py13
-rw-r--r--tests/signing/tests.py18
17 files changed, 210 insertions, 8 deletions
diff --git a/django/conf/__init__.py b/django/conf/__init__.py
index ec7efadf46..a10c8e5958 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):
"""
@@ -198,6 +204,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.
@@ -244,6 +253,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 15a3b0ce88..9bad0b4e42 100644
--- a/django/contrib/auth/tokens.py
+++ b/django/contrib/auth/tokens.py
@@ -11,9 +11,14 @@ class PasswordResetTokenGenerator:
reset mechanism.
"""
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
- algorithm = 'sha256'
+ algorithm = None
secret = settings.SECRET_KEY
+ def __init__(self):
+ # 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):
"""
Return a token that can be used once to do a password reset
diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py
index b69c2a11da..4c2c7ef164 100644
--- a/django/core/checks/security/base.py
+++ b/django/core/checks/security/base.py
@@ -115,6 +115,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
@@ -223,3 +228,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 14a2b41a4c..eddbcc3a1b 100644
--- a/docs/internals/deprecation.txt
+++ b/docs/internals/deprecation.txt
@@ -99,6 +99,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 d0cc4b163b..f4c8e8e213 100644
--- a/docs/ref/checks.txt
+++ b/docs/ref/checks.txt
@@ -474,6 +474,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 2306ed26fe..a4b4828a5a 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 <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
``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 <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
--------------
@@ -794,6 +815,8 @@ Miscellaneous
<django.template.backends.django.DjangoTemplates>` option in
:setting:`OPTIONS <TEMPLATES-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 5538225acb..f86f8c6b27 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):
@@ -29,6 +31,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 = (