diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 5bf4cf13ae09..d462f82acf05 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -25,6 +25,12 @@ DEFAULT_STORAGE_ALIAS = "default" STATICFILES_STORAGE_ALIAS = "staticfiles" +# RemovedInDjango70Warning. +SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG = ( + "The SIGNED_COOKIE_LEGACY_SALT_FALLBACK transitional setting is " + "deprecated. Remove it from your settings once legacy signed cookies " + "have expired. They will not be accepted in Django 7.0." +) # RemovedInDjango70Warning. USE_BLANK_CHOICE_DASH_DEPRECATED_MSG = ( "The USE_BLANK_CHOICE_DASH setting is deprecated. If you wish to define " @@ -149,6 +155,12 @@ def __setattr__(self, name, value): self.__dict__.pop(name, None) # RemovedInDjango70Warning. + if name == "SIGNED_COOKIE_LEGACY_SALT_FALLBACK": + _show_settings_deprecation_warning( + SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG, + RemovedInDjango70Warning, + ) + # RemovedInDjango70Warning. if name == "USE_BLANK_CHOICE_DASH": _show_settings_deprecation_warning( USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, RemovedInDjango70Warning @@ -260,6 +272,13 @@ def __init__(self, settings_module): self._explicit_settings.add(setting) # RemovedInDjango70Warning. + if "SIGNED_COOKIE_LEGACY_SALT_FALLBACK" in self._explicit_settings: + warnings.warn( + SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG, + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + # RemovedInDjango70Warning. if "USE_BLANK_CHOICE_DASH" in self._explicit_settings: warnings.warn( USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, @@ -319,6 +338,13 @@ def __getattr__(self, name): def __setattr__(self, name, value): self._deleted.discard(name) + # RemovedInDjango70Warning. + if name == "SIGNED_COOKIE_LEGACY_SALT_FALLBACK": + _show_settings_deprecation_warning( + SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG, + RemovedInDjango70Warning, + ) + # RemovedInDjango70Warning. if name == "USE_BLANK_CHOICE_DASH": _show_settings_deprecation_warning( USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, RemovedInDjango70Warning diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index ea70c761056a..a00c5c39220e 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -561,6 +561,7 @@ def gettext_noop(s): # SIGNING # ########### +SIGNED_COOKIE_LEGACY_SALT_FALLBACK = False SIGNING_BACKEND = "django.core.signing.TimestampSigner" ######## diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py index 84154dd1c06e..5a45ee1dff36 100644 --- a/django/core/mail/backends/smtp.py +++ b/django/core/mail/backends/smtp.py @@ -45,6 +45,7 @@ def __init__( super().__init__(**kwargs) self.fail_silently = fail_silently self.connection = None + self._partial_connection = None self._lock = threading.RLock() # RemovedInDjango70Warning. @@ -120,6 +121,11 @@ def open(self): # Nothing to do if the connection is already open. return False + # If a connection was partially opened before, close it. + if self._partial_connection is not None: + self._close_connection(self._partial_connection) + self._partial_connection = None + # If local_hostname is not specified, socket.getfqdn() gets used. # For performance, we use the cached FQDN for local_hostname. connection_params = {"local_hostname": DNS_NAME.get_fqdn()} @@ -128,39 +134,51 @@ def open(self): if self.use_ssl: connection_params["context"] = self.ssl_context try: - self.connection = self.connection_class( + self._partial_connection = self.connection_class( self.host, self.port, **connection_params ) # TLS/SSL are mutually exclusive, so only attempt TLS over # non-secure connections. if not self.use_ssl and self.use_tls: - self.connection.starttls(context=self.ssl_context) + self._partial_connection.starttls(context=self.ssl_context) if self.username and self.password: - self.connection.login(self.username, self.password) + self._partial_connection.login(self.username, self.password) + + # Don't set connection until it's fully configured. + self.connection = self._partial_connection + self._partial_connection = None + return True except OSError: if not self.fail_silently: raise + def _close_connection(self, connection): + try: + connection.quit() + except (ssl.SSLError, smtplib.SMTPServerDisconnected): + # This happens when calling quit() on a TLS connection + # sometimes, or when the connection was already disconnected + # by the server. + connection.close() + except smtplib.SMTPException: + if self.fail_silently: + return + raise + def close(self): """Close the connection to the email server.""" - if self.connection is None: - return - try: + if self._partial_connection is not None: try: - self.connection.quit() - except (ssl.SSLError, smtplib.SMTPServerDisconnected): - # This happens when calling quit() on a TLS connection - # sometimes, or when the connection was already disconnected - # by the server. - self.connection.close() - except smtplib.SMTPException: - if self.fail_silently: - return - raise - finally: - self.connection = None + self._close_connection(self._partial_connection) + finally: + self._partial_connection = None + if self.connection is not None: + try: + self._close_connection(self.connection) + finally: + self.connection = None def send_messages(self, email_messages): """ diff --git a/django/core/signing.py b/django/core/signing.py index 86740edc27c4..56b2c35a021f 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -119,6 +119,33 @@ def _cookie_signer_key(key): return b"django.http.cookies" + force_bytes(key) +def _cookie_signer_salt(cookie_name, salt=""): + # Prefix the salt length so (cookie_name, salt) pairs can't collide. + return f"django.http.cookies.v2:{len(salt)}:{salt}{cookie_name}" + + +# RemovedInDjango70Warning: When the deprecation ends, remove. +def _cookie_signer_legacy_salt(cookie_name, salt=""): + return cookie_name + salt + + +def _unsign_cookie(signed_value, *, cookie_name, salt="", max_age=None): + try: + # RemovedInDjango70Warning: When the deprecation ends, replace the + # whole function body with this single return statement. + return get_cookie_signer(salt=_cookie_signer_salt(cookie_name, salt)).unsign( + signed_value, max_age=max_age + ) + except BadSignature as exc: + if settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK and not isinstance( + exc, SignatureExpired + ): + return get_cookie_signer( + salt=_cookie_signer_legacy_salt(cookie_name, salt) + ).unsign(signed_value, max_age=max_age) + raise + + def get_cookie_signer(salt="django.core.signing.get_cookie_signer"): Signer = import_string(settings.SIGNING_BACKEND) return Signer( diff --git a/django/http/request.py b/django/http/request.py index 28208d57a995..414aa38de3ca 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -250,8 +250,8 @@ def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None): else: raise try: - value = signing.get_cookie_signer(salt=key + salt).unsign( - cookie_value, max_age=max_age + value = signing._unsign_cookie( + cookie_value, cookie_name=key, salt=salt, max_age=max_age ) except signing.BadSignature: if default is not RAISE_ERROR: diff --git a/django/http/response.py b/django/http/response.py index 3b948d6eed43..dbefb173593c 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -291,7 +291,9 @@ def setdefault(self, key, value): self.headers.setdefault(key, value) def set_signed_cookie(self, key, value, salt="", **kwargs): - value = signing.get_cookie_signer(salt=key + salt).sign(value) + value = signing.get_cookie_signer( + salt=signing._cookie_signer_salt(key, salt) + ).sign(value) return self.set_cookie(key, value, **kwargs) def delete_cookie(self, key, path="/", domain=None, samesite=None): diff --git a/django/middleware/cache.py b/django/middleware/cache.py index 678fde68a891..8ac1178b12b0 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -41,6 +41,8 @@ * This middleware also sets ETag, Last-Modified, Expires and Cache-Control headers on the response object. +* If the request had an Authorization header and the response was not marked + "Cache-Control: public", the response will vary on Authorization. """ import time @@ -53,6 +55,7 @@ has_vary_header, learn_cache_key, patch_response_headers, + patch_vary_headers, ) from django.utils.deprecation import MiddlewareMixin from django.utils.http import parse_http_date_safe @@ -102,8 +105,8 @@ def process_response(self, request, response): # Don't cache responses when the Cache-Control header is set to # private, no-cache, or no-store. - cache_control = response.get("Cache-Control", ()) - if any( + cache_control = response.get("Cache-Control", "").lower() + if cache_control and any( directive in cache_control for directive in ( "private", @@ -130,6 +133,12 @@ def process_response(self, request, response): # max-age was set to 0, don't cache. return response patch_response_headers(response, timeout) + # Make the response vary on Authorization if the request bears that + # header, unless allowed by "public" per RFC 9111, Section 3.5. No + # exceptions are made for "s-maxage" and "must-revalidate" since these + # are not currently implemented by Django. + if request.headers.get("Authorization") and "public" not in cache_control: + patch_vary_headers(response, ("Authorization",)) if timeout and response.status_code == 200: cache_key = learn_cache_key( request, response, timeout, self.key_prefix, cache=self.cache diff --git a/django/utils/cache.py b/django/utils/cache.py index f2cbd1d03379..8fe856ed7ead 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -332,8 +332,8 @@ def has_vary_header(response, header_query): if not response.has_header("Vary"): return False vary_headers = cc_delim_re.split(response.headers["Vary"]) - existing_headers = {header.lower() for header in vary_headers} - return header_query.lower() in existing_headers + existing_headers = {header.lower().strip() for header in vary_headers} + return header_query.lower().strip() in existing_headers def _i18n_cache_key_suffix(request, cache_key): diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 5a2aa5d5b837..4b586fef29cb 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -36,6 +36,9 @@ details on these changes. * The ``URLIZE_ASSUME_HTTPS`` transitional setting will be removed. +* The ``SIGNED_COOKIE_LEGACY_SALT_FALLBACK`` transitional setting will be + removed. + * Using a percent sign in a column alias or annotation will raise ``ValueError``. diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index fba7a538268f..8706422521cd 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -307,6 +307,34 @@ Usage example: >>> Experiment.objects.filter(start_datetime__year=Extract("end_datetime", "year")).count() 1 +.. admonition:: ``Extract()`` uses the active timezone + + When :setting:`USE_TZ` is ``True``, Django saves values in ``UTC`` (or the + connection's configured timezone). Since ``tzinfo`` defaults to + :setting:`TIME_ZONE` when not provided, you may need to provide an explicit + ``tzinfo`` to compare extracted values. The following example demonstrates + what happens when "Europe/Berlin" is active and how to adjust for this: + + .. code-block:: pycon + + >>> from django.utils import timezone + >>> from datetime import UTC, datetime + >>> from django.db.models.functions import ExtractHour + >>> start = datetime(2015, 6, 15, 14, 30, 50, 321) + >>> start = timezone.make_aware(start) + >>> exp = Experiment.objects.create(start_datetime=start) + >>> find_this_exp = Experiment.objects.annotate( + ... extract_hour_start=ExtractHour("start_datetime") + ... ).filter(extract_hour_start=start.hour) + # Comparing local time to UTC finds no results. + >>> find_this_exp.count() + 0 + >>> find_this_exp_adjusted = Experiment.objects.annotate( + ... extract_hour_start=ExtractHour("start_datetime", tzinfo=UTC) + ... ).filter(extract_hour_start=start.hour) + >>> find_this_exp_adjusted.count() + 1 + ``DateField`` extracts ~~~~~~~~~~~~~~~~~~~~~~ @@ -584,16 +612,16 @@ The timezone offset for Melbourne in the example date above is +10:00. The values returned when this timezone is active will be: * "year": 2015-01-01 00:00:00+11:00 -* "quarter": 2015-04-01 00:00:00+10:00 +* "quarter": 2015-04-01 00:00:00+11:00 * "month": 2015-06-01 00:00:00+10:00 -* "week": 2015-06-16 00:00:00+10:00 +* "week": 2015-06-15 00:00:00+10:00 * "day": 2015-06-16 00:00:00+10:00 * "hour": 2015-06-16 00:00:00+10:00 * "minute": 2015-06-16 00:30:00+10:00 * "second": 2015-06-16 00:30:50+10:00 -The year has an offset of +11:00 because the result transitioned into daylight -saving time. +The year and quarter have an offset of +11:00 because each result falls before +the daylight saving time transition. Each ``kind`` above has a corresponding ``Trunc`` subclass (listed below) that should typically be used instead of the more verbose equivalent, @@ -634,6 +662,34 @@ Usage example: 2015-06-15 14:30:50.000321 2015-06-15 14:40:02.000123 +.. admonition:: ``Trunc()`` produces naive datetimes + + When :setting:`USE_TZ` is ``True``, Django saves values in ``UTC`` (or the + connection's configured timezone). Since ``tzinfo`` defaults to + :setting:`TIME_ZONE` when not provided, you may need to provide an explicit + ``tzinfo`` to compare truncated values. The following example demonstrates + what happens when "Europe/Berlin" is active and how to adjust for this: + + .. code-block:: pycon + + >>> from django.utils import timezone + >>> from datetime import UTC, datetime + >>> from django.db.models.functions import TruncSecond + >>> start = datetime(2015, 6, 15, 14, 30, 50, 321) + >>> start = timezone.make_aware(start) + >>> exp = Experiment.objects.create(start_datetime=start) + >>> find_this_exp = Experiment.objects.annotate( + ... trunc_start=TruncSecond("start_datetime") + ... ).filter(trunc_start__lte=start) + # Comparing local time to UTC finds no results. + >>> find_this_exp.count() + 0 + >>> find_this_exp_adjusted = Experiment.objects.annotate( + ... trunc_start=TruncSecond("start_datetime", tzinfo=UTC) + ... ).filter(trunc_start__lte=start) + >>> find_this_exp_adjusted.count() + 1 + ``DateField`` truncation ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index eebd0cd053f0..1ca5cb0c7824 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -418,11 +418,14 @@ Methods no longer valid. If you provide the ``default`` argument the exception will be suppressed and that default value will be returned instead. - The optional ``salt`` argument can be used to provide extra protection - against brute force attacks on your secret key. If supplied, the - ``max_age`` argument will be checked against the signed timestamp - attached to the cookie value to ensure the cookie is not older than - ``max_age`` seconds. + The optional ``salt`` argument can be used to put the cookie into a + separate signature namespace. If supplied, the ``max_age`` argument will + be checked against the signed timestamp attached to the cookie value to + ensure the cookie is not older than ``max_age`` seconds. + + Cookies signed by older Django versions are accepted by default for + backwards compatibility. Set :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` + to ``False`` to reject them. For example: @@ -445,6 +448,11 @@ Methods See :doc:`cryptographic signing ` for more information. + .. versionchanged:: 5.2.15 + + In older versions, cookies signed with distinct ``(key, salt)`` pairs + that concatenate to the same string could be used interchangeably. + .. method:: HttpRequest.is_secure() Returns ``True`` if the request is secure; that is, if it was made with @@ -1065,8 +1073,9 @@ Methods Like :meth:`~HttpResponse.set_cookie`, but :doc:`cryptographic signing ` the cookie before setting it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`. - You can use the optional ``salt`` argument for added key strength, but - you will need to remember to pass it to the corresponding + You can use the optional ``salt`` argument to put the cookie into a + separate signature namespace, but you will need to remember to pass it to + the corresponding :meth:`HttpRequest.get_signed_cookie` call. .. method:: HttpResponse.delete_cookie(key, path='/', domain=None, samesite=None) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 4cd1fa7368d2..31762338b99a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2831,6 +2831,31 @@ precedence and will be applied instead. See See also :setting:`DATE_FORMAT` and :setting:`SHORT_DATE_FORMAT`. +.. setting:: SIGNED_COOKIE_LEGACY_SALT_FALLBACK + +``SIGNED_COOKIE_LEGACY_SALT_FALLBACK`` +--------------------------------------- + +.. versionadded:: 5.2.15 + +Default: ``False`` + +Controls whether :meth:`~django.http.HttpRequest.get_signed_cookie` accepts +cookies signed with Django's historical signed-cookie salt derivation based on +``key + salt``. + +Set this to ``True`` to accept those legacy signed cookies in addition to +cookies signed with Django's current unambiguous signed-cookie salt derivation. + +.. versionchanged:: 6.1 + + In older versions, the default was ``True``. + +.. deprecated:: 6.1 + + This transitional setting will be removed in Django 7.0, when legacy signed + cookies will no longer be accepted. + .. setting:: SIGNING_BACKEND ``SIGNING_BACKEND`` @@ -4081,6 +4106,7 @@ HTTP * :setting:`SECURE_REFERRER_POLICY` * :setting:`SECURE_SSL_HOST` * :setting:`SECURE_SSL_REDIRECT` +* :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` * :setting:`SIGNING_BACKEND` * :setting:`USE_X_FORWARDED_HOST` * :setting:`USE_X_FORWARDED_PORT` diff --git a/docs/releases/5.2.15.txt b/docs/releases/5.2.15.txt index 9163528f3ff7..aed69baaafe0 100644 --- a/docs/releases/5.2.15.txt +++ b/docs/releases/5.2.15.txt @@ -5,3 +5,75 @@ Django 5.2.15 release notes *June 3, 2026* Django 5.2.15 fixes five security issues with severity "low" in 5.2.14. + +CVE-2026-6873: Signed cookie salt namespace collision +===================================================== + +:meth:`~django.http.HttpRequest.get_signed_cookie` derived the signing salt by +concatenating the cookie name (``key``) and ``salt`` arguments. When distinct +name and salt pairs produced the same concatenation, cookies could be accepted +in a context different from the one where they were signed. + +Cookies are now signed with an unambiguous salt derivation. For backwards +compatibility, cookies signed by older Django versions are accepted until +Django 7.0. Projects affected by the above ambiguity should set +:setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` to ``False`` to reject older +cookies immediately. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend +============================================================================================ + +When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could +leave a partially-initialized connection that would subsequently be reused for +sending email without encryption. This can occur with ``fail_silently=True``, +as used by :func:`~django.core.mail.send_mail` and +:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others. +Connections configured with :setting:`EMAIL_USE_SSL` are not affected. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-8404: Potential exposure of private data via case-sensitive ``Cache-Control`` directives +================================================================================================= + +:class:`~django.middleware.cache.UpdateCacheMiddleware` and +:func:`~django.views.decorators.cache.cache_page` incorrectly cached responses +marked with private ``Cache-Control`` directives when using mixed or uppercase +values (e.g. ``Private``). + +The :func:`~django.views.decorators.cache.cache_control` decorator and +:func:`~django.utils.cache.patch_cache_control` function were not affected, +since they normalize directives to lowercase. This issue only affects responses +where ``Cache-Control`` is set manually. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-35193: Potential exposure of private data via missing ``Vary: Authorization`` +====================================================================================== + +:class:`~django.middleware.cache.UpdateCacheMiddleware` and +:func:`~django.views.decorators.cache.cache_page` decorator allowed responses +to requests bearing an ``Authorization`` header (and without ``Cache-Control: +public``) to be cached. To conform with the existing mechanism for constructing +cache keys, responses to these requests will now :ref:`vary on +` ``Authorization``. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-48587: Potential exposure of private data via whitespace padding in ``Vary`` header +============================================================================================ + +:class:`~django.middleware.cache.UpdateCacheMiddleware` incorrectly cached +responses whose ``Vary`` header values contained leading or trailing +whitespace. Because ``has_vary_header()`` failed to strip that, a ``Vary: *`` +header value with surrounding whitespace was not recognized as containing the +wildcard, causing it to be stored and potentially served from the cache when it +should not have been. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.6.txt b/docs/releases/6.0.6.txt index 110f89932a88..e8d1e1629a53 100644 --- a/docs/releases/6.0.6.txt +++ b/docs/releases/6.0.6.txt @@ -7,6 +7,78 @@ Django 6.0.6 release notes Django 6.0.6 fixes five security issues with severity "low" and one bug in 6.0.5. +CVE-2026-6873: Signed cookie salt namespace collision +===================================================== + +:meth:`~django.http.HttpRequest.get_signed_cookie` derived the signing salt by +concatenating the cookie name (``key``) and ``salt`` arguments. When distinct +name and salt pairs produced the same concatenation, cookies could be accepted +in a context different from the one where they were signed. + +Cookies are now signed with an unambiguous salt derivation. For backwards +compatibility, cookies signed by older Django versions are accepted until +Django 7.0. Projects affected by the above ambiguity should set +:setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` to ``False`` to reject older +cookies immediately. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend +============================================================================================ + +When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could +leave a partially-initialized connection that would subsequently be reused for +sending email without encryption. This can occur with ``fail_silently=True``, +as used by :func:`~django.core.mail.send_mail` and +:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others. +Connections configured with :setting:`EMAIL_USE_SSL` are not affected. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-8404: Potential exposure of private data via case-sensitive ``Cache-Control`` directives +================================================================================================= + +:class:`~django.middleware.cache.UpdateCacheMiddleware` and +:func:`~django.views.decorators.cache.cache_page` incorrectly cached responses +marked with private ``Cache-Control`` directives when using mixed or uppercase +values (e.g. ``Private``). + +The :func:`~django.views.decorators.cache.cache_control` decorator and +:func:`~django.utils.cache.patch_cache_control` function were not affected, +since they normalize directives to lowercase. This issue only affects responses +where ``Cache-Control`` is set manually. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-35193: Potential exposure of private data via missing ``Vary: Authorization`` +====================================================================================== + +:class:`~django.middleware.cache.UpdateCacheMiddleware` and +:func:`~django.views.decorators.cache.cache_page` decorator allowed responses +to requests bearing an ``Authorization`` header (and without ``Cache-Control: +public``) to be cached. To conform with the existing mechanism for constructing +cache keys, responses to these requests will now :ref:`vary on +` ``Authorization``. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-48587: Potential exposure of private data via whitespace padding in ``Vary`` header +============================================================================================ + +:class:`~django.middleware.cache.UpdateCacheMiddleware` incorrectly cached +responses whose ``Vary`` header values contained leading or trailing +whitespace. Because ``has_vary_header()`` failed to strip that, a ``Vary: *`` +header value with surrounding whitespace was not recognized as containing the +wildcard, causing it to be stored and potentially served from the cache when it +should not have been. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/docs/releases/6.0.7.txt b/docs/releases/6.0.7.txt new file mode 100644 index 000000000000..9addb28b406a --- /dev/null +++ b/docs/releases/6.0.7.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.7 release notes +========================== + +*Expected July 1, 2026* + +Django 6.0.7 fixes several bugs in 6.0.6. + +Bugfixes +======== + +* ... diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index f9fb779ff35e..7ef149f40c97 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -331,6 +331,13 @@ Requests and Responses the :func:`~django.shortcuts.redirect` shortcut, now accept a ``max_length`` parameter to override the default maximum URL length limit. +Security +~~~~~~~~ + +* Signed cookies now use an unambiguous salt derivation by default. Set + :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` to ``True`` to continue + accepting legacy signed cookies. + Serialization ~~~~~~~~~~~~~ @@ -508,6 +515,9 @@ Miscellaneous * The minimum supported version of SQLite is increased from 3.31.0 to 3.37.0. +* The default value of the transitional setting + :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` is now ``False``. + * :class:`~django.contrib.contenttypes.fields.GenericForeignKey` now uses a separate descriptor class: the private ``GenericForeignKeyDescriptor``. @@ -625,6 +635,9 @@ Miscellaneous * The :setting:`USE_BLANK_CHOICE_DASH` transitional setting is deprecated. +* The :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` transitional setting is + deprecated. + * The undocumented ``get_placeholder`` method of :class:`~django.db.models.Field` is deprecated in favor of the newly introduced ``get_placeholder_sql`` method, which has the same input signature diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 45f50bd3f007..1f4d3de710dc 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -39,6 +39,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.7 6.0.6 6.0.5 6.0.4 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index fc79d72d2595..243895497822 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,64 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +June 3, 2026 - :cve:`2026-6873` +------------------------------- + +Signed cookie salt namespace collision in +``django.http.HttpRequest.get_signed_cookie``. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <42bdfd74ff85eb9ddf8fd444e2359afd9add59c8>` +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <594360cbf58be7f56eb6da96d58644297c99ef85>` + +June 3, 2026 - :cve:`2026-7666` +------------------------------- + +Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend. +`Full description +`__ + +* Django 6.1 :commit:`(patch) ` +* Django 6.0 :commit:`(patch) <625a670c467aa3118c0f8ae1e0df14dbebb3bf68>` +* Django 5.2 :commit:`(patch) <4e47d2b800435bcbfd1301ef3250b9c7fb8fa670>` + +June 3, 2026 - :cve:`2026-8404` +------------------------------- + +Potential exposure of private data via case-sensitive ``Cache-Control`` +directives in ``UpdateCacheMiddleware``. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <130467c8b4d05a69b885363aa7d47386e4f5d6a9>` +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <366d9ae6e8d1469c04e9ebdc1bcd098fc14a3b1e>` + +June 3, 2026 - :cve:`2026-35193` +-------------------------------- + +Potential exposure of private data via missing ``Vary: Authorization`` in +``UpdateCacheMiddleware``. +`Full description +`__ + +* Django 6.1 :commit:`(patch) ` +* Django 6.0 :commit:`(patch) <664652f1a2dd80d8a4cd491b4313cad915ae6669>` +* Django 5.2 :commit:`(patch) <050a3dc276f9142067260e990e4d8d42d5e32863>` + +June 3, 2026 - :cve:`2026-48587` +-------------------------------- + +Potential exposure of private data via whitespace padding in ``Vary`` header. +`Full description +`__ + +* Django 6.1 :commit:`(patch) ` +* Django 6.0 :commit:`(patch) <1721035a72624aad7b38dd19b14013efd94b24b8>` +* Django 5.2 :commit:`(patch) <9b62b0af71a14c657d19d95371630ba839e83d9a>` + May 5, 2026 - :cve:`2026-5766` ------------------------------ diff --git a/docs/topics/cache.txt b/docs/topics/cache.txt index 37384c3b8aed..9af28a9324c4 100644 --- a/docs/topics/cache.txt +++ b/docs/topics/cache.txt @@ -1403,6 +1403,18 @@ second argument. For more on Vary headers, see the :rfc:`official Vary spec <9110#section-12.5.5>`. +.. admonition:: ``CacheMiddleware`` varies on ``Authorization`` automatically + + Although varying on ``Authorization`` is not strictly necessary given that + :rfc:`9111#section-3.5` allows caches to avoid reusing authenticated + responses, Django's ``CacheMiddleware`` adds ``Authorization`` to the + ``Vary`` header to simplify construction of cache keys. + +.. versionchanged:: 6.0.6 + + Previously, ``UpdateCacheMiddleware`` did not vary on ``Authorization`` for + requests bearing that header. + Controlling cache: Using other headers ====================================== diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 65ca88512584..c2167b8b3f10 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -4,7 +4,6 @@ import io import os import pickle -import re import shutil import sys import tempfile @@ -57,7 +56,9 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone, translation from django.utils.cache import ( + cc_delim_re, get_cache_key, + has_vary_header, learn_cache_key, patch_cache_control, patch_vary_headers, @@ -2308,8 +2309,6 @@ def test_patch_cache_control(self): ), ) - cc_delim_re = re.compile(r"\s*,\s*") - for initial_cc, newheaders, expected_cc in tests: with self.subTest(initial_cc=initial_cc, newheaders=newheaders): response = HttpResponse() @@ -2319,6 +2318,31 @@ def test_patch_cache_control(self): parts = set(cc_delim_re.split(response.headers["Cache-Control"])) self.assertEqual(parts, expected_cc) + def test_has_vary_header(self): + tests = [ + ("*", "*", True), + ("Cookie, *", "*", True), + ("Cookie,*", "*", True), + ("Cookie , *", "*", True), + # Surronding whitespace on values must be stripped independently of + # the comma delimiter. + ("* ", "*", True), + (" *", "*", True), + ("Cookie, * ", "*", True), + (" Cookie", "Cookie", True), + ("Cookie", "*", False), + ("*", "Cookie", False), + ("cookie", "Cookie", True), + ("Cookie", "cookie", True), + ] + + for header_value, header_query, has_match in tests: + with self.subTest(header_value=header_value, header_query=header_query): + response = HttpResponse() + response.headers["Vary"] = header_value + + self.assertIs(has_vary_header(response, header_query), has_match) + @override_settings( CACHES={ @@ -2622,9 +2646,15 @@ def hello_world_view_patch_vary_headers_asterisk(request, value): return response +def hello_world_view_patch_vary_headers_asterisk_space(request, value): + response = HttpResponse("Hello World %s" % value) + patch_vary_headers(response, (" * ",)) + return response + + def hello_world_view_vary_headers_includes_asterisk(request, value): response = HttpResponse("Hello World %s" % value) - response["Vary"] = "Cookie, *, Pony" + response["Vary"] = "Cookie, * , Pony" return response @@ -2851,20 +2881,25 @@ def test_cache_control_not_cached(self): Responses with 'Cache-Control: private/no-cache/no-store' are not cached. """ - for cc in ("private", "no-cache", "no-store"): + for cc in ("private", "no-cache", "no-store", "PRIVATE", "NO-store"): with self.subTest(cache_control=cc): - view_with_cache = cache_page(3)( - cache_control(**{cc: True})(hello_world_view) - ) + # Cannot use @cache_control() as it lowercases directives. + @cache_page(3) + def view(request, value): + return HttpResponse( + f"Hello World {value}", headers={"Cache-Control": cc} + ) + request = self.factory.get("/view/") - response = view_with_cache(request, "1") + response = view(request, "1") self.assertEqual(response.content, b"Hello World 1") - response = view_with_cache(request, "2") + response = view(request, "2") self.assertEqual(response.content, b"Hello World 2") def test_vary_asterisk_not_cached(self): views_with_cache = ( cache_page(3)(hello_world_view_patch_vary_headers_asterisk), + cache_page(3)(hello_world_view_patch_vary_headers_asterisk_space), cache_page(3)(hello_world_view_vary_headers_includes_asterisk), ) for view in views_with_cache: @@ -2875,6 +2910,30 @@ def test_vary_asterisk_not_cached(self): response = view(request, "2") self.assertEqual(response.content, b"Hello World 2") + def test_vary_on_authorization_for_authorization_header(self): + view_with_cache = cache_page(3)(hello_world_view) + request = self.factory.get("/view/", headers={"Authorization": "token"}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), True) + + def test_no_vary_on_authorization_for_empty_authorization_header(self): + view_with_cache = cache_page(3)(hello_world_view) + request = self.factory.get("/view/", headers={"Authorization": ""}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), False) + + def test_authorization_header_exceptions(self): + """ + Responses to requests with an ``Authorization`` header are not made to + vary on ``Authorization`` when ``Cache-Control: public`` is present. + ``s-maxage`` and ``must-revalidate`` are also exceptions per RFC 9111, + Section 3.5, but Django does not implement them. + """ + view_with_cache = cache_page(3)(cache_control(public=True)(hello_world_view)) + request = self.factory.get("/view/", headers={"Authorization": "token"}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), False) + def test_sensitive_cookie_not_cached(self): """ Django must prevent caching of responses that set a user-specific (and diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index b4dd160f3264..7c4b87282777 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -1948,3 +1948,29 @@ def test_trunc_invalid_field_with_timezone(self): DTModel.objects.annotate( hour_melb=Trunc("start_time", "hour", tzinfo=melb), ).get() + + @override_settings(TIME_ZONE="Australia/Melbourne") + def test_trunc_filter_non_utc_active(self): + melb = zoneinfo.ZoneInfo("Australia/Melbourne") + start_datetime = datetime.datetime(2015, 6, 15, 1, 30, 1, 321, tzinfo=melb) + end_datetime = datetime.datetime(2015, 6, 16, 2, 30, 1, 123, tzinfo=melb) + self.create_model(start_datetime, end_datetime) + + # Trunc() yields a naive datetime (June 15), but the RHS is aware. + self.assertEqual( + DTModel.objects.annotate( + day_melb=Trunc("start_datetime", "day"), + ) + .filter(day_melb__lt=start_datetime) + .count(), + 0, + ) + # When TIME_ZONE != "UTC", supply tzinfo explicitly. + self.assertEqual( + DTModel.objects.annotate( + day_melb=Trunc("start_datetime", "day", tzinfo=datetime.UTC), + ) + .filter(day_melb__lt=start_datetime) + .count(), + 1, + ) diff --git a/tests/deprecation/test_signed_cookie_legacy_salt_fallback.py b/tests/deprecation/test_signed_cookie_legacy_salt_fallback.py new file mode 100644 index 000000000000..4b51707e45ef --- /dev/null +++ b/tests/deprecation/test_signed_cookie_legacy_salt_fallback.py @@ -0,0 +1,41 @@ +import sys +from types import ModuleType + +from django.conf import ( + SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG, + LazySettings, + Settings, + settings, +) +from django.test import SimpleTestCase +from django.utils.deprecation import RemovedInDjango70Warning + + +# RemovedInDjango70Warning. +class SignedCookieLegacySaltFallbackDeprecationTests(SimpleTestCase): + msg = SIGNED_COOKIE_LEGACY_SALT_DEPRECATED_MSG + + def test_override_settings_warning(self): + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + with self.settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True): + pass + + def test_settings_init_warning(self): + settings_module = ModuleType("fake_settings_module") + settings_module.USE_TZ = False + settings_module.SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True + sys.modules["fake_settings_module"] = settings_module + try: + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + Settings("fake_settings_module") + finally: + del sys.modules["fake_settings_module"] + + def test_settings_assignment_warning(self): + lazy_settings = LazySettings() + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + lazy_settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True + + def test_access(self): + # Warning is not raised on access. + self.assertEqual(settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK, False) diff --git a/tests/mail/test_backends.py b/tests/mail/test_backends.py index 0782b18afe03..eee501de7047 100644 --- a/tests/mail/test_backends.py +++ b/tests/mail/test_backends.py @@ -795,10 +795,9 @@ def test_auth_attempted(self): backend = self.create_backend( username="not empty username", password="not empty password" ) - with mock.patch("smtplib.SMTP.login") as mock_smtp_login, backend: - # Using backend as context manager opens the connection and - # attempts login. - pass + self.addCleanup(backend.close) + with mock.patch("smtplib.SMTP.login") as mock_smtp_login: + backend.open() mock_smtp_login.assert_called_once_with( "not empty username", "not empty password" ) @@ -810,8 +809,12 @@ def test_server_open(self): backend = self.create_backend() self.assertIsNone(backend.connection) opened = backend.open() + self.assertIsNotNone(backend.connection) + self.assertIsNone(backend._partial_connection) backend.close() self.assertIs(opened, True) + self.assertIsNone(backend.connection) + self.assertIsNone(backend._partial_connection) def test_reopen_connection(self): backend = self.create_backend() @@ -819,6 +822,26 @@ def test_reopen_connection(self): backend.connection = mock.Mock(spec=object()) self.assertIs(backend.open(), False) + def test_reopen_replaces_partial_connection(self): + backend = self.create_backend(username="not empty", password="not empty") + self.addCleanup(backend.close) + + error = "SMTP AUTH extension not supported by server." + with self.assertRaisesMessage(SMTPException, error): + backend.open() + self.assertIsNone(backend.connection) + self.assertIsNotNone(backend._partial_connection) + partial_conn = backend._partial_connection + + with self.assertRaisesMessage(SMTPException, error): + backend.open() + self.assertIsNone(backend.connection) + self.assertIsNotNone(backend._partial_connection) + self.assertNotEqual(backend._partial_connection, partial_conn) + + self.assertIsNone(partial_conn.sock) + self.assertIsNotNone(backend._partial_connection.sock) + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_TLS=True) def test_email_tls_use_settings(self): @@ -915,19 +938,20 @@ def test_ssl_context_uses_ssl_certfile_and_keyfile(self): def test_email_tls_attempts_starttls(self): backend = self.create_backend(use_tls=True) + self.addCleanup(backend.close) self.assertIs(backend.use_tls, True) with self.assertRaisesMessage( SMTPException, "STARTTLS extension not supported by server." ): - with backend: - pass + backend.open() + self.assertIsNone(backend.connection) def test_email_ssl_attempts_ssl_connection(self): backend = self.create_backend(use_ssl=True) self.assertIs(backend.use_ssl, True) with self.assertRaises(SSLError): - with backend: - pass + backend.open() + self.assertIsNone(backend.connection) def test_connection_timeout_default(self): backend = self.create_backend() @@ -944,10 +968,10 @@ def __init__(self, *args, **kwargs): myemailbackend = MyEmailBackend( host=self.smtp_controller.hostname, port=self.smtp_controller.port ) + self.addCleanup(myemailbackend.close) myemailbackend.open() self.assertEqual(myemailbackend.timeout, 42) self.assertEqual(myemailbackend.connection.timeout, 42) - myemailbackend.close() # RemovedInDjango70Warning. @override_settings(EMAIL_TIMEOUT=10) @@ -1158,5 +1182,7 @@ def test_fail_silently_on_connection_error(self): """ with self.assertRaises(ConnectionError): self.backend.open() + self.assertIsNone(self.backend.connection) self.backend.fail_silently = True self.backend.open() + self.assertIsNone(self.backend.connection) diff --git a/tests/signed_cookies_tests/tests.py b/tests/signed_cookies_tests/tests.py index 876887d883f1..279da5ea59c3 100644 --- a/tests/signed_cookies_tests/tests.py +++ b/tests/signed_cookies_tests/tests.py @@ -3,7 +3,8 @@ from django.core import signing from django.http import HttpRequest, HttpResponse from django.test import SimpleTestCase, override_settings -from django.test.utils import freeze_time +from django.test.utils import freeze_time, ignore_warnings +from django.utils.deprecation import RemovedInDjango70Warning class SignedCookieTest(SimpleTestCase): @@ -27,6 +28,62 @@ def test_can_use_salt(self): with self.assertRaises(signing.BadSignature): request.get_signed_cookie("a", salt="two") + def test_salt_namespace_is_unambiguous(self): + response = HttpResponse() + response.set_signed_cookie("a", "hello", salt="bc") + request = HttpRequest() + request.COOKIES["ab"] = response.cookies["a"].value + with self.assertRaises(signing.BadSignature): + request.get_signed_cookie("ab", salt="c") + + # RemovedInDjango70Warning: When the deprecation ends, remove this test. + @ignore_warnings(category=RemovedInDjango70Warning) + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_expired_legacy_cookie_raises_signature_expired(self): + with freeze_time(123456789): + request = HttpRequest() + request.COOKIES["a"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + with freeze_time(123456800): + with self.assertRaises(signing.SignatureExpired): + request.get_signed_cookie("a", salt="bc", max_age=10) + + # RemovedInDjango70Warning: When the deprecation ends, remove this test. + @ignore_warnings(category=RemovedInDjango70Warning) + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_legacy_salt_namespace_is_accepted(self): + request = HttpRequest() + # Simulate an attack along the lines of CVE-2026-6873, where a value + # for the "a" cookie is submitted as the value for another cookie. + request.COOKIES["ab"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + # No protection since SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True. + self.assertEqual(request.get_signed_cookie("ab", salt="c"), "hello") + + # RemovedInDjango70Warning: When the deprecation ends, remove this test. + def test_legacy_salt_namespace_not_accepted(self): + request = HttpRequest() + request.COOKIES["a"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + with self.assertRaises(signing.BadSignature): + request.get_signed_cookie("a", salt="bc") + + # RemovedInDjango70Warning: When the deprecation ends, remove this test. + @ignore_warnings(category=RemovedInDjango70Warning) + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_expired_new_style_cookie_does_not_fallback_to_legacy_salt(self): + with freeze_time(123456789): + response = HttpResponse() + response.set_signed_cookie("a", "hello", salt="bc") + request = HttpRequest() + request.COOKIES["a"] = response.cookies["a"].value + with freeze_time(123456800): + with self.assertRaises(signing.SignatureExpired): + request.get_signed_cookie("a", salt="bc", max_age=10) + def test_detects_tampering(self): response = HttpResponse() response.set_signed_cookie("c", "hello")