Fixed #30360 -- Added support for secret key rotation.

Thanks Florian Apolloner for the implementation idea.

Co-authored-by: Andreas Pelme <andreas@pelme.se>
Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
Co-authored-by: Vuyisile Ndlovu <terrameijar@gmail.com>
This commit is contained in:
tschilling 2021-12-13 21:47:03 -06:00 committed by Mariusz Felisiak
parent ba4a6880d1
commit 0dcd549bbe
18 changed files with 364 additions and 56 deletions

View File

@ -188,6 +188,7 @@ class Settings:
"INSTALLED_APPS", "INSTALLED_APPS",
"TEMPLATE_DIRS", "TEMPLATE_DIRS",
"LOCALE_PATHS", "LOCALE_PATHS",
"SECRET_KEY_FALLBACKS",
) )
self._explicit_settings = set() self._explicit_settings = set()
for setting in dir(mod): for setting in dir(mod):

View File

@ -272,6 +272,10 @@ IGNORABLE_404_URLS = []
# loudly. # loudly.
SECRET_KEY = '' SECRET_KEY = ''
# List of secret keys used to verify the validity of signatures. This allows
# secret key rotation.
SECRET_KEY_FALLBACKS = []
# Default file storage mechanism that holds media. # Default file storage mechanism that holds media.
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'

View File

@ -13,6 +13,7 @@ class PasswordResetTokenGenerator:
key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator" key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
algorithm = None algorithm = None
_secret = None _secret = None
_secret_fallbacks = None
def __init__(self): def __init__(self):
self.algorithm = self.algorithm or 'sha256' self.algorithm = self.algorithm or 'sha256'
@ -25,12 +26,26 @@ class PasswordResetTokenGenerator:
secret = property(_get_secret, _set_secret) secret = property(_get_secret, _set_secret)
def _get_fallbacks(self):
if self._secret_fallbacks is None:
return settings.SECRET_KEY_FALLBACKS
return self._secret_fallbacks
def _set_fallbacks(self, fallbacks):
self._secret_fallbacks = fallbacks
secret_fallbacks = property(_get_fallbacks, _set_fallbacks)
def make_token(self, user): def make_token(self, user):
""" """
Return a token that can be used once to do a password reset Return a token that can be used once to do a password reset
for the given user. for the given user.
""" """
return self._make_token_with_timestamp(user, self._num_seconds(self._now())) return self._make_token_with_timestamp(
user,
self._num_seconds(self._now()),
self.secret,
)
def check_token(self, user, token): def check_token(self, user, token):
""" """
@ -50,7 +65,13 @@ class PasswordResetTokenGenerator:
return False return False
# Check that the timestamp/uid has not been tampered with # Check that the timestamp/uid has not been tampered with
if not constant_time_compare(self._make_token_with_timestamp(user, ts), token): for secret in [self.secret, *self.secret_fallbacks]:
if constant_time_compare(
self._make_token_with_timestamp(user, ts, secret),
token,
):
break
else:
return False return False
# Check the timestamp is within limit. # Check the timestamp is within limit.
@ -59,14 +80,14 @@ class PasswordResetTokenGenerator:
return True return True
def _make_token_with_timestamp(self, user, timestamp): def _make_token_with_timestamp(self, user, timestamp, secret):
# timestamp is number of seconds since 2001-1-1. Converted to base 36, # timestamp is number of seconds since 2001-1-1. Converted to base 36,
# this gives us a 6 digit string until about 2069. # this gives us a 6 digit string until about 2069.
ts_b36 = int_to_base36(timestamp) ts_b36 = int_to_base36(timestamp)
hash_string = salted_hmac( hash_string = salted_hmac(
self.key_salt, self.key_salt,
self._make_hash_value(user, timestamp), self._make_hash_value(user, timestamp),
secret=self.secret, secret=secret,
algorithm=self.algorithm, algorithm=self.algorithm,
).hexdigest()[::2] # Limit to shorten the URL. ).hexdigest()[::2] # Limit to shorten the URL.
return "%s-%s" % (ts_b36, hash_string) return "%s-%s" % (ts_b36, hash_string)

View File

@ -16,6 +16,15 @@ SECRET_KEY_INSECURE_PREFIX = 'django-insecure-'
SECRET_KEY_MIN_LENGTH = 50 SECRET_KEY_MIN_LENGTH = 50
SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5 SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
SECRET_KEY_WARNING_MSG = (
f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters, less than "
f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters, or it's prefixed "
f"with '{SECRET_KEY_INSECURE_PREFIX}' indicating that it was generated "
f"automatically by Django. Please generate a long and random value, "
f"otherwise many of Django's security-critical features will be "
f"vulnerable to attack."
)
W001 = Warning( W001 = Warning(
"You do not have 'django.middleware.security.SecurityMiddleware' " "You do not have 'django.middleware.security.SecurityMiddleware' "
"in your MIDDLEWARE so the SECURE_HSTS_SECONDS, " "in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
@ -72,15 +81,7 @@ W008 = Warning(
) )
W009 = Warning( W009 = Warning(
"Your SECRET_KEY has less than %(min_length)s characters, less than " SECRET_KEY_WARNING_MSG % 'SECRET_KEY',
"%(min_unique_chars)s unique characters, or it's prefixed with "
"'%(insecure_prefix)s' indicating that it was generated automatically by "
"Django. Please generate a long and random SECRET_KEY, otherwise many of "
"Django's security-critical features will be vulnerable to attack." % {
'min_length': SECRET_KEY_MIN_LENGTH,
'min_unique_chars': SECRET_KEY_MIN_UNIQUE_CHARACTERS,
'insecure_prefix': SECRET_KEY_INSECURE_PREFIX,
},
id='security.W009', id='security.W009',
) )
@ -131,6 +132,8 @@ E024 = Error(
id='security.E024', id='security.E024',
) )
W025 = Warning(SECRET_KEY_WARNING_MSG, id='security.W025')
def _security_middleware(): def _security_middleware():
return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE
@ -196,6 +199,14 @@ def check_ssl_redirect(app_configs, **kwargs):
return [] if passed_check else [W008] return [] if passed_check else [W008]
def _check_secret_key(secret_key):
return (
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
len(secret_key) >= SECRET_KEY_MIN_LENGTH and
not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
)
@register(Tags.security, deploy=True) @register(Tags.security, deploy=True)
def check_secret_key(app_configs, **kwargs): def check_secret_key(app_configs, **kwargs):
try: try:
@ -203,14 +214,28 @@ def check_secret_key(app_configs, **kwargs):
except (ImproperlyConfigured, AttributeError): except (ImproperlyConfigured, AttributeError):
passed_check = False passed_check = False
else: else:
passed_check = ( passed_check = _check_secret_key(secret_key)
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and
len(secret_key) >= SECRET_KEY_MIN_LENGTH and
not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
)
return [] if passed_check else [W009] return [] if passed_check else [W009]
@register(Tags.security, deploy=True)
def check_secret_key_fallbacks(app_configs, **kwargs):
warnings = []
try:
fallbacks = settings.SECRET_KEY_FALLBACKS
except (ImproperlyConfigured, AttributeError):
warnings.append(
Warning(W025.msg % 'SECRET_KEY_FALLBACKS', id=W025.id)
)
else:
for index, key in enumerate(fallbacks):
if not _check_secret_key(key):
warnings.append(
Warning(W025.msg % f'SECRET_KEY_FALLBACKS[{index}]', id=W025.id)
)
return warnings
@register(Tags.security, deploy=True) @register(Tags.security, deploy=True)
def check_debug(app_configs, **kwargs): def check_debug(app_configs, **kwargs):
passed_check = not settings.DEBUG passed_check = not settings.DEBUG

View File

@ -97,10 +97,18 @@ def base64_hmac(salt, value, key, algorithm='sha1'):
return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode() return b64_encode(salted_hmac(salt, value, key, algorithm=algorithm).digest()).decode()
def _cookie_signer_key(key):
# SECRET_KEYS items may be str or bytes.
return b'django.http.cookies' + force_bytes(key)
def get_cookie_signer(salt='django.core.signing.get_cookie_signer'): def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
Signer = import_string(settings.SIGNING_BACKEND) Signer = import_string(settings.SIGNING_BACKEND)
key = force_bytes(settings.SECRET_KEY) # SECRET_KEY may be str or bytes. return Signer(
return Signer(b'django.http.cookies' + key, salt=salt) key=_cookie_signer_key(settings.SECRET_KEY),
fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
salt=salt,
)
class JSONSerializer: class JSONSerializer:
@ -135,18 +143,41 @@ def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer,
return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress) return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress)
def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None): def loads(
s,
key=None,
salt='django.core.signing',
serializer=JSONSerializer,
max_age=None,
fallback_keys=None,
):
""" """
Reverse of dumps(), raise BadSignature if signature fails. Reverse of dumps(), raise BadSignature if signature fails.
The serializer is expected to accept a bytestring. The serializer is expected to accept a bytestring.
""" """
return TimestampSigner(key, salt=salt).unsign_object(s, serializer=serializer, max_age=max_age) return TimestampSigner(key, salt=salt, fallback_keys=fallback_keys).unsign_object(
s,
serializer=serializer,
max_age=max_age,
)
class Signer: class Signer:
def __init__(self, key=None, sep=':', salt=None, algorithm=None): def __init__(
self,
key=None,
sep=':',
salt=None,
algorithm=None,
fallback_keys=None,
):
self.key = key or settings.SECRET_KEY self.key = key or settings.SECRET_KEY
self.fallback_keys = (
fallback_keys
if fallback_keys is not None
else settings.SECRET_KEY_FALLBACKS
)
self.sep = sep self.sep = sep
if _SEP_UNSAFE.match(self.sep): if _SEP_UNSAFE.match(self.sep):
raise ValueError( raise ValueError(
@ -156,8 +187,9 @@ class Signer:
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 or 'sha256' self.algorithm = algorithm or 'sha256'
def signature(self, value): def signature(self, value, key=None):
return base64_hmac(self.salt + 'signer', value, self.key, algorithm=self.algorithm) key = key or self.key
return base64_hmac(self.salt + 'signer', value, key, algorithm=self.algorithm)
def sign(self, value): def sign(self, value):
return '%s%s%s' % (value, self.sep, self.signature(value)) return '%s%s%s' % (value, self.sep, self.signature(value))
@ -166,8 +198,9 @@ class Signer:
if self.sep not in signed_value: if self.sep not in signed_value:
raise BadSignature('No "%s" found in value' % self.sep) raise BadSignature('No "%s" found in value' % self.sep)
value, sig = signed_value.rsplit(self.sep, 1) value, sig = signed_value.rsplit(self.sep, 1)
if constant_time_compare(sig, self.signature(value)): for key in [self.key, *self.fallback_keys]:
return value if constant_time_compare(sig, self.signature(value, key)):
return value
raise BadSignature('Signature "%s" does not match' % sig) raise BadSignature('Signature "%s" does not match' % sig)
def sign_object(self, obj, serializer=JSONSerializer, compress=False): def sign_object(self, obj, serializer=JSONSerializer, compress=False):

View File

@ -59,6 +59,22 @@ or from a file::
with open('/etc/secret_key.txt') as f: with open('/etc/secret_key.txt') as f:
SECRET_KEY = f.read().strip() SECRET_KEY = f.read().strip()
If rotating secret keys, you may use :setting:`SECRET_KEY_FALLBACKS`::
import os
SECRET_KEY = os.environ['CURRENT_SECRET_KEY']
SECRET_KEY_FALLBACKS = [
os.environ['OLD_SECRET_KEY'],
]
Ensure that old secret keys are removed from ``SECRET_KEY_FALLBACKS`` in a
timely manner.
.. versionchanged:: 4.1
The ``SECRET_KEY_FALLBACKS`` setting was added to support rotating secret
keys.
:setting:`DEBUG` :setting:`DEBUG`
---------------- ----------------

View File

@ -457,8 +457,8 @@ The following checks are run if you use the :option:`check --deploy` option:
* **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters, * **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters,
less than 5 unique characters, or it's prefixed with ``'django-insecure-'`` less than 5 unique characters, or it's prefixed with ``'django-insecure-'``
indicating that it was generated automatically by Django. Please generate a indicating that it was generated automatically by Django. Please generate a
long and random ``SECRET_KEY``, otherwise many of Django's security-critical long and random value, otherwise many of Django's security-critical features
features will be vulnerable to attack. will be vulnerable to attack.
* **security.W010**: You have :mod:`django.contrib.sessions` in your * **security.W010**: You have :mod:`django.contrib.sessions` in your
:setting:`INSTALLED_APPS` but you have not set :setting:`INSTALLED_APPS` but you have not set
:setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session :setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session
@ -511,6 +511,12 @@ The following checks are run if you use the :option:`check --deploy` option:
to an invalid value. to an invalid value.
* **security.E024**: You have set the * **security.E024**: You have set the
:setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value. :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value.
* **security.W025**: Your
:setting:`SECRET_KEY_FALLBACKS[n] <SECRET_KEY_FALLBACKS>` has less than 50
characters, less than 5 unique characters, or it's prefixed with
``'django-insecure-'`` indicating that it was generated automatically by
Django. Please generate a long and random value, otherwise many of Django's
security-critical features will be vulnerable to attack.
The following checks verify that your security-related settings are correctly The following checks verify that your security-related settings are correctly
configured: configured:

View File

@ -2291,9 +2291,11 @@ The secret key is used for:
* Any usage of :doc:`cryptographic signing </topics/signing>`, unless a * Any usage of :doc:`cryptographic signing </topics/signing>`, unless a
different key is provided. different key is provided.
If you rotate your secret key, all of the above will be invalidated. When a secret key is no longer set as :setting:`SECRET_KEY` or contained within
Secret keys are not used for passwords of users and key rotation will not :setting:`SECRET_KEY_FALLBACKS` all of the above will be invalidated. When
affect them. rotating your secret key, you should move the old key to
:setting:`SECRET_KEY_FALLBACKS` temporarily. Secret keys are not used for
passwords of users and key rotation will not affect them.
.. note:: .. note::
@ -2301,6 +2303,36 @@ affect them.
startproject <startproject>` creates a unique ``SECRET_KEY`` for startproject <startproject>` creates a unique ``SECRET_KEY`` for
convenience. convenience.
.. setting:: SECRET_KEY_FALLBACKS
``SECRET_KEY_FALLBACKS``
------------------------
.. versionadded:: 4.1
Default: ``[]``
A list of fallback secret keys for a particular Django installation. These are
used to allow rotation of the ``SECRET_KEY``.
In order to rotate your secret keys, set a new ``SECRET_KEY`` and move the
previous value to the beginning of ``SECRET_KEY_FALLBACKS``. Then remove the
old values from the end of the ``SECRET_KEY_FALLBACKS`` when you are ready to
expire the sessions, password reset tokens, and so on, that make use of them.
.. note::
Signing operations are computationally expensive. Having multiple old key
values in ``SECRET_KEY_FALLBACKS`` adds additional overhead to all checks
that don't match an earlier key.
As such, fallback values should be removed after an appropriate period,
allowing for key rotation.
Uses of the secret key values shouldn't assume that they are text or bytes.
Every use should go through :func:`~django.utils.encoding.force_str` or
:func:`~django.utils.encoding.force_bytes` to convert it to the desired type.
.. setting:: SECURE_CONTENT_TYPE_NOSNIFF .. setting:: SECURE_CONTENT_TYPE_NOSNIFF
``SECURE_CONTENT_TYPE_NOSNIFF`` ``SECURE_CONTENT_TYPE_NOSNIFF``
@ -3725,6 +3757,7 @@ Security
* :setting:`CSRF_USE_SESSIONS` * :setting:`CSRF_USE_SESSIONS`
* :setting:`SECRET_KEY` * :setting:`SECRET_KEY`
* :setting:`SECRET_KEY_FALLBACKS`
* :setting:`X_FRAME_OPTIONS` * :setting:`X_FRAME_OPTIONS`
Serialization Serialization

View File

@ -254,7 +254,8 @@ Requests and Responses
Security Security
~~~~~~~~ ~~~~~~~~
* ... * The new :setting:`SECRET_KEY_FALLBACKS` setting allows providing a list of
values for secret key rotation.
Serialization Serialization
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -912,8 +912,9 @@ function.
Since Since
:meth:`~django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash()` :meth:`~django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash()`
is based on :setting:`SECRET_KEY`, updating your site to use a new secret is based on :setting:`SECRET_KEY`, secret key values must be
will invalidate all existing sessions. rotated to avoid invalidating existing sessions when updating your site to
use a new secret. See :setting:`SECRET_KEY_FALLBACKS` for details.
.. _built-in-auth-views: .. _built-in-auth-views:

View File

@ -123,13 +123,15 @@ and the :setting:`SECRET_KEY` setting.
.. warning:: .. warning::
**If the SECRET_KEY is not kept secret and you are using the** **If the ``SECRET_KEY`` or ``SECRET_KEY_FALLBACKS`` are not kept secret and
``django.contrib.sessions.serializers.PickleSerializer``, **this can you are using the**
lead to arbitrary remote code execution.** ``django.contrib.sessions.serializers.PickleSerializer``, **this can lead
to arbitrary remote code execution.**
An attacker in possession of the :setting:`SECRET_KEY` can not only An attacker in possession of the :setting:`SECRET_KEY` or
generate falsified session data, which your site will trust, but also :setting:`SECRET_KEY_FALLBACKS` can not only generate falsified session
remotely execute arbitrary code, as the data is serialized using pickle. data, which your site will trust, but also remotely execute arbitrary code,
as the data is serialized using pickle.
If you use cookie-based sessions, pay extra care that your secret key is If you use cookie-based sessions, pay extra care that your secret key is
always kept completely secret, for any system which might be remotely always kept completely secret, for any system which might be remotely
@ -323,11 +325,12 @@ cookie backend*.
For example, here's an attack scenario if you use :mod:`pickle` to serialize For example, here's an attack scenario if you use :mod:`pickle` to serialize
session data. If you're using the :ref:`signed cookie session backend session data. If you're using the :ref:`signed cookie session backend
<cookie-session-backend>` and :setting:`SECRET_KEY` is known by an attacker <cookie-session-backend>` and :setting:`SECRET_KEY` (or any key of
(there isn't an inherent vulnerability in Django that would cause it to leak), :setting:`SECRET_KEY_FALLBACKS`) is known by an attacker (there isn't an
the attacker could insert a string into their session which, when unpickled, inherent vulnerability in Django that would cause it to leak), the attacker
executes arbitrary code on the server. The technique for doing so is simple and could insert a string into their session which, when unpickled, executes
easily available on the internet. Although the cookie session storage signs the arbitrary code on the server. The technique for doing so is simple and easily
available on the internet. Although the cookie session storage signs the
cookie-stored data to prevent tampering, a :setting:`SECRET_KEY` leak cookie-stored data to prevent tampering, a :setting:`SECRET_KEY` leak
immediately escalates to a remote code execution vulnerability. immediately escalates to a remote code execution vulnerability.
@ -359,8 +362,8 @@ Bundled serializers
.. class:: serializers.PickleSerializer .. class:: serializers.PickleSerializer
Supports arbitrary Python objects, but, as described above, can lead to a Supports arbitrary Python objects, but, as described above, can lead to a
remote code execution vulnerability if :setting:`SECRET_KEY` becomes known remote code execution vulnerability if :setting:`SECRET_KEY` or any key of
by an attacker. :setting:`SECRET_KEY_FALLBACKS` becomes known by an attacker.
.. deprecated:: 4.1 .. deprecated:: 4.1

View File

@ -296,7 +296,8 @@ security protection of the web server, operating system and other components.
* Django does not throttle requests to authenticate users. To protect against * Django does not throttle requests to authenticate users. To protect against
brute-force attacks against the authentication system, you may consider brute-force attacks against the authentication system, you may consider
deploying a Django plugin or web server module to throttle these requests. deploying a Django plugin or web server module to throttle these requests.
* Keep your :setting:`SECRET_KEY` a secret. * Keep your :setting:`SECRET_KEY`, and :setting:`SECRET_KEY_FALLBACKS` if in
use, secret.
* It is a good idea to limit the accessibility of your caching system and * It is a good idea to limit the accessibility of your caching system and
database using a firewall. database using a firewall.
* Take a look at the Open Web Application Security Project (OWASP) `Top 10 * Take a look at the Open Web Application Security Project (OWASP) `Top 10

View File

@ -25,8 +25,8 @@ You may also find signing useful for the following:
protected resource, for example a downloadable file that a user has protected resource, for example a downloadable file that a user has
paid for. paid for.
Protecting the ``SECRET_KEY`` Protecting ``SECRET_KEY`` and ``SECRET_KEY_FALLBACKS``
============================= ======================================================
When you create a new Django project using :djadmin:`startproject`, the When you create a new Django project using :djadmin:`startproject`, the
``settings.py`` file is generated automatically and gets a random ``settings.py`` file is generated automatically and gets a random
@ -34,6 +34,14 @@ When you create a new Django project using :djadmin:`startproject`, the
data -- it is vital you keep this secure, or attackers could use it to data -- it is vital you keep this secure, or attackers could use it to
generate their own signed values. generate their own signed values.
:setting:`SECRET_KEY_FALLBACKS` can be used to rotate secret keys. The
values will not be used to sign data, but if specified, they will be used to
validate signed data and must be kept secure.
.. versionchanged:: 4.1
The ``SECRET_KEY_FALLBACKS`` setting was added.
Using the low-level API Using the low-level API
======================= =======================
@ -93,13 +101,19 @@ 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=None) .. class:: Signer(key=None, sep=':', salt=None, algorithm=None, fallback_keys=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`, it defaults to ``'sha256'``. :py:mod:`hashlib`, it defaults to ``'sha256'``. ``fallback_keys`` is a list
of additional values used to validate signed data, defaults to
:setting:`SECRET_KEY_FALLBACKS`.
.. versionchanged:: 4.1
The ``fallback_keys`` argument was added.
Using the ``salt`` argument Using the ``salt`` argument
--------------------------- ---------------------------
@ -221,7 +235,11 @@ and tuples) if you pass in a tuple, you will get a list from
Returns URL-safe, signed base64 compressed JSON string. Serialized object Returns URL-safe, signed base64 compressed JSON string. Serialized object
is signed using :class:`~TimestampSigner`. is signed using :class:`~TimestampSigner`.
.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None) .. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None, fallback_keys=None)
Reverse of ``dumps()``, raises ``BadSignature`` if signature fails. Reverse of ``dumps()``, raises ``BadSignature`` if signature fails.
Checks ``max_age`` (in seconds) if given. Checks ``max_age`` (in seconds) if given.
.. versionchanged:: 4.1
The ``fallback_keys`` argument was added.

View File

@ -140,3 +140,39 @@ class TokenGeneratorTest(TestCase):
msg = 'The SECRET_KEY setting must not be empty.' msg = 'The SECRET_KEY setting must not be empty.'
with self.assertRaisesMessage(ImproperlyConfigured, msg): with self.assertRaisesMessage(ImproperlyConfigured, msg):
default_token_generator.secret default_token_generator.secret
def test_check_token_secret_fallbacks(self):
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
p1 = PasswordResetTokenGenerator()
p1.secret = 'secret'
tk = p1.make_token(user)
p2 = PasswordResetTokenGenerator()
p2.secret = 'newsecret'
p2.secret_fallbacks = ['secret']
self.assertIs(p1.check_token(user, tk), True)
self.assertIs(p2.check_token(user, tk), True)
@override_settings(
SECRET_KEY='secret',
SECRET_KEY_FALLBACKS=['oldsecret'],
)
def test_check_token_secret_key_fallbacks(self):
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
p1 = PasswordResetTokenGenerator()
p1.secret = 'oldsecret'
tk = p1.make_token(user)
p2 = PasswordResetTokenGenerator()
self.assertIs(p2.check_token(user, tk), True)
@override_settings(
SECRET_KEY='secret',
SECRET_KEY_FALLBACKS=['oldsecret'],
)
def test_check_token_secret_key_fallbacks_override(self):
user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
p1 = PasswordResetTokenGenerator()
p1.secret = 'oldsecret'
tk = p1.make_token(user)
p2 = PasswordResetTokenGenerator()
p2.secret_fallbacks = []
self.assertIs(p2.check_token(user, tk), False)

View File

@ -1,5 +1,5 @@
from django.conf import settings from django.conf import settings
from django.core.checks.messages import Error from django.core.checks.messages import Error, Warning
from django.core.checks.security import base, csrf, sessions from django.core.checks.security import base, csrf, sessions
from django.core.management.utils import get_random_secret_key from django.core.management.utils import get_random_secret_key
from django.test import SimpleTestCase from django.test import SimpleTestCase
@ -414,6 +414,79 @@ class CheckSecretKeyTest(SimpleTestCase):
self.assertEqual(base.check_secret_key(None), [base.W009]) self.assertEqual(base.check_secret_key(None), [base.W009])
class CheckSecretKeyFallbacksTest(SimpleTestCase):
@override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'ab'])
def test_okay_secret_key_fallbacks(self):
self.assertEqual(
len(settings.SECRET_KEY_FALLBACKS[0]),
base.SECRET_KEY_MIN_LENGTH,
)
self.assertGreater(
len(set(settings.SECRET_KEY_FALLBACKS[0])),
base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
)
self.assertEqual(base.check_secret_key_fallbacks(None), [])
def test_no_secret_key_fallbacks(self):
with self.settings(SECRET_KEY_FALLBACKS=None):
del settings.SECRET_KEY_FALLBACKS
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS', id=base.W025.id),
])
@override_settings(SECRET_KEY_FALLBACKS=[
base.SECRET_KEY_INSECURE_PREFIX + get_random_secret_key()
])
def test_insecure_secret_key_fallbacks(self):
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
])
@override_settings(SECRET_KEY_FALLBACKS=[('abcdefghijklmnopqrstuvwx' * 2) + 'a'])
def test_low_length_secret_key_fallbacks(self):
self.assertEqual(
len(settings.SECRET_KEY_FALLBACKS[0]),
base.SECRET_KEY_MIN_LENGTH - 1,
)
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
])
@override_settings(SECRET_KEY_FALLBACKS=['abcd' * 20])
def test_low_entropy_secret_key_fallbacks(self):
self.assertGreater(
len(settings.SECRET_KEY_FALLBACKS[0]),
base.SECRET_KEY_MIN_LENGTH,
)
self.assertLess(
len(set(settings.SECRET_KEY_FALLBACKS[0])),
base.SECRET_KEY_MIN_UNIQUE_CHARACTERS,
)
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[0]', id=base.W025.id),
])
@override_settings(SECRET_KEY_FALLBACKS=[
('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
'badkey',
])
def test_multiple_keys(self):
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
])
@override_settings(SECRET_KEY_FALLBACKS=[
('abcdefghijklmnopqrstuvwx' * 2) + 'ab',
'badkey1',
'badkey2',
])
def test_multiple_bad_keys(self):
self.assertEqual(base.check_secret_key_fallbacks(None), [
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[1]', id=base.W025.id),
Warning(base.W025.msg % 'SECRET_KEY_FALLBACKS[2]', id=base.W025.id),
])
class CheckDebugTest(SimpleTestCase): class CheckDebugTest(SimpleTestCase):
@override_settings(DEBUG=True) @override_settings(DEBUG=True)
def test_debug_true(self): def test_debug_true(self):

View File

@ -483,6 +483,7 @@ class TestListSettings(SimpleTestCase):
"INSTALLED_APPS", "INSTALLED_APPS",
"TEMPLATE_DIRS", "TEMPLATE_DIRS",
"LOCALE_PATHS", "LOCALE_PATHS",
"SECRET_KEY_FALLBACKS",
) )
def test_tuple_settings(self): def test_tuple_settings(self):

View File

@ -1,7 +1,7 @@
import datetime import datetime
from django.core import signing from django.core import signing
from django.test import SimpleTestCase from django.test import SimpleTestCase, override_settings
from django.test.utils import freeze_time from django.test.utils import freeze_time
from django.utils.crypto import InvalidAlgorithm from django.utils.crypto import InvalidAlgorithm
@ -178,6 +178,39 @@ class TestSigner(SimpleTestCase):
with self.assertRaisesMessage(ValueError, msg % sep): with self.assertRaisesMessage(ValueError, msg % sep):
signing.Signer(sep=sep) signing.Signer(sep=sep)
def test_verify_with_non_default_key(self):
old_signer = signing.Signer('secret')
new_signer = signing.Signer('newsecret', fallback_keys=['othersecret', 'secret'])
signed = old_signer.sign('abc')
self.assertEqual(new_signer.unsign(signed), 'abc')
def test_sign_unsign_multiple_keys(self):
"""The default key is a valid verification key."""
signer = signing.Signer('secret', fallback_keys=['oldsecret'])
signed = signer.sign('abc')
self.assertEqual(signer.unsign(signed), 'abc')
@override_settings(
SECRET_KEY='secret',
SECRET_KEY_FALLBACKS=['oldsecret'],
)
def test_sign_unsign_ignore_secret_key_fallbacks(self):
old_signer = signing.Signer('oldsecret')
signed = old_signer.sign('abc')
signer = signing.Signer(fallback_keys=[])
with self.assertRaises(signing.BadSignature):
signer.unsign(signed)
@override_settings(
SECRET_KEY='secret',
SECRET_KEY_FALLBACKS=['oldsecret'],
)
def test_default_keys_verification(self):
old_signer = signing.Signer('oldsecret')
signed = old_signer.sign('abc')
signer = signing.Signer()
self.assertEqual(signer.unsign(signed), 'abc')
class TestTimestampSigner(SimpleTestCase): class TestTimestampSigner(SimpleTestCase):

View File

@ -1459,6 +1459,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
""" """
sensitive_settings = [ sensitive_settings = [
'SECRET_KEY', 'SECRET_KEY',
'SECRET_KEY_FALLBACKS',
'PASSWORD', 'PASSWORD',
'API_KEY', 'API_KEY',
'AUTH_TOKEN', 'AUTH_TOKEN',
@ -1475,6 +1476,7 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
""" """
sensitive_settings = [ sensitive_settings = [
'SECRET_KEY', 'SECRET_KEY',
'SECRET_KEY_FALLBACKS',
'PASSWORD', 'PASSWORD',
'API_KEY', 'API_KEY',
'AUTH_TOKEN', 'AUTH_TOKEN',