diff options
| author | Paul McMillan <paul@mcmillan.ws> | 2026-05-08 16:13:58 -0400 |
|---|---|---|
| committer | Natalia <124304+nessita@users.noreply.github.com> | 2026-06-03 08:37:26 -0300 |
| commit | 70d36515b9cc71700105a14b275583070d48b689 (patch) | |
| tree | f9a598a2deaf24321f9b13b3141c80265f425a28 | |
| parent | 09fdc06346b167ff6231a4cb80b85ae67b53c715 (diff) | |
Fixed CVE-2026-6873 -- Prevented signed cookie salt namespace collisions.
Made signed cookies derive their signer namespace from an injective
encoding of `(name, salt)` while preserving compatibility with legacy
`name + salt` cookies behind SIGNED_COOKIE_LEGACY_SALT_FALLBACK.
Thanks Peng Zhou for the report, and Shai Berger, Markus Holterman,
Jake Howard, and Paul McMillan for reviews.
Co-authored-by: Jacob Walls <jacobtylerwalls@gmail.com>
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
| -rw-r--r-- | django/conf/global_settings.py | 1 | ||||
| -rw-r--r-- | django/core/signing.py | 24 | ||||
| -rw-r--r-- | django/http/request.py | 4 | ||||
| -rw-r--r-- | django/http/response.py | 4 | ||||
| -rw-r--r-- | docs/ref/request-response.txt | 23 | ||||
| -rw-r--r-- | docs/ref/settings.txt | 19 | ||||
| -rw-r--r-- | docs/releases/5.2.15.txt | 17 | ||||
| -rw-r--r-- | docs/releases/6.0.6.txt | 17 | ||||
| -rw-r--r-- | tests/signed_cookies_tests/tests.py | 50 |
9 files changed, 149 insertions, 10 deletions
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index ea70c76105..12d76b05bb 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -561,6 +561,7 @@ AUTH_PASSWORD_VALIDATORS = [] # SIGNING # ########### +SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True SIGNING_BACKEND = "django.core.signing.TimestampSigner" ######## diff --git a/django/core/signing.py b/django/core/signing.py index 86740edc27..33f51d16aa 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -119,6 +119,30 @@ 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}" + + +def _cookie_signer_legacy_salt(cookie_name, salt=""): + return cookie_name + salt + + +def _unsign_cookie(signed_value, *, cookie_name, salt="", max_age=None): + try: + 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 28208d57a9..414aa38de3 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -250,8 +250,8 @@ class HttpRequest: 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 3b948d6eed..dbefb17359 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -291,7 +291,9 @@ class HttpResponseBase: 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/docs/ref/request-response.txt b/docs/ref/request-response.txt index eebd0cd053..1ca5cb0c78 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 </topics/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 </topics/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 4cd1fa7368..b69cafad5a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2831,6 +2831,24 @@ 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: ``True`` + +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 ``False`` to reject those legacy signed cookies and only accept +cookies signed with Django's current unambiguous signed-cookie salt derivation. +This transitional setting will be removed in Django 7.0, when the legacy signed +cookies will no longer be accepted. + .. setting:: SIGNING_BACKEND ``SIGNING_BACKEND`` @@ -4081,6 +4099,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 9163528f3f..3659714a08 100644 --- a/docs/releases/5.2.15.txt +++ b/docs/releases/5.2.15.txt @@ -5,3 +5,20 @@ 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 +<severity-levels>`. diff --git a/docs/releases/6.0.6.txt b/docs/releases/6.0.6.txt index 110f89932a..dcd63121fc 100644 --- a/docs/releases/6.0.6.txt +++ b/docs/releases/6.0.6.txt @@ -7,6 +7,23 @@ 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 +<severity-levels>`. + Bugfixes ======== diff --git a/tests/signed_cookies_tests/tests.py b/tests/signed_cookies_tests/tests.py index 876887d883..62bd3d192d 100644 --- a/tests/signed_cookies_tests/tests.py +++ b/tests/signed_cookies_tests/tests.py @@ -6,6 +6,7 @@ from django.test import SimpleTestCase, override_settings from django.test.utils import freeze_time +@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=False) class SignedCookieTest(SimpleTestCase): def test_can_set_and_read_signed_cookies(self): response = HttpResponse() @@ -27,6 +28,55 @@ class SignedCookieTest(SimpleTestCase): 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") + + @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) + + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_legacy_salt_namespace_is_accepted_by_default(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") + + 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") + + @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") |
