summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul McMillan <paul@mcmillan.ws>2026-05-08 16:13:58 -0400
committerNatalia <124304+nessita@users.noreply.github.com>2026-06-03 08:37:26 -0300
commit70d36515b9cc71700105a14b275583070d48b689 (patch)
treef9a598a2deaf24321f9b13b3141c80265f425a28
parent09fdc06346b167ff6231a4cb80b85ae67b53c715 (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.py1
-rw-r--r--django/core/signing.py24
-rw-r--r--django/http/request.py4
-rw-r--r--django/http/response.py4
-rw-r--r--docs/ref/request-response.txt23
-rw-r--r--docs/ref/settings.txt19
-rw-r--r--docs/releases/5.2.15.txt17
-rw-r--r--docs/releases/6.0.6.txt17
-rw-r--r--tests/signed_cookies_tests/tests.py50
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")