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:
parent
ba4a6880d1
commit
0dcd549bbe
|
@ -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):
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,7 +198,8 @@ 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]:
|
||||||
|
if constant_time_compare(sig, self.signature(value, key)):
|
||||||
return value
|
return value
|
||||||
raise BadSignature('Signature "%s" does not match' % sig)
|
raise BadSignature('Signature "%s" does not match' % sig)
|
||||||
|
|
||||||
|
|
|
@ -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`
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue