Skip to content

Commit c807d9c

Browse files
PaulMcMillannessita
authored andcommitted
[6.0.x] 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> Backport of 70d3651 from main.
1 parent 98a75e3 commit c807d9c

9 files changed

Lines changed: 149 additions & 10 deletions

File tree

django/conf/global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,7 @@ def gettext_noop(s):
550550
# SIGNING #
551551
###########
552552

553+
SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True
553554
SIGNING_BACKEND = "django.core.signing.TimestampSigner"
554555

555556
########

django/core/signing.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ def _cookie_signer_key(key):
107107
return b"django.http.cookies" + force_bytes(key)
108108

109109

110+
def _cookie_signer_salt(cookie_name, salt=""):
111+
# Prefix the salt length so (cookie_name, salt) pairs can't collide.
112+
return f"django.http.cookies.v2:{len(salt)}:{salt}{cookie_name}"
113+
114+
115+
def _cookie_signer_legacy_salt(cookie_name, salt=""):
116+
return cookie_name + salt
117+
118+
119+
def _unsign_cookie(signed_value, *, cookie_name, salt="", max_age=None):
120+
try:
121+
return get_cookie_signer(salt=_cookie_signer_salt(cookie_name, salt)).unsign(
122+
signed_value, max_age=max_age
123+
)
124+
except BadSignature as exc:
125+
if settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK and not isinstance(
126+
exc, SignatureExpired
127+
):
128+
return get_cookie_signer(
129+
salt=_cookie_signer_legacy_salt(cookie_name, salt)
130+
).unsign(signed_value, max_age=max_age)
131+
raise
132+
133+
110134
def get_cookie_signer(salt="django.core.signing.get_cookie_signer"):
111135
Signer = import_string(settings.SIGNING_BACKEND)
112136
return Signer(

django/http/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None):
246246
else:
247247
raise
248248
try:
249-
value = signing.get_cookie_signer(salt=key + salt).unsign(
250-
cookie_value, max_age=max_age
249+
value = signing._unsign_cookie(
250+
cookie_value, cookie_name=key, salt=salt, max_age=max_age
251251
)
252252
except signing.BadSignature:
253253
if default is not RAISE_ERROR:

django/http/response.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,9 @@ def setdefault(self, key, value):
284284
self.headers.setdefault(key, value)
285285

286286
def set_signed_cookie(self, key, value, salt="", **kwargs):
287-
value = signing.get_cookie_signer(salt=key + salt).sign(value)
287+
value = signing.get_cookie_signer(
288+
salt=signing._cookie_signer_salt(key, salt)
289+
).sign(value)
288290
return self.set_cookie(key, value, **kwargs)
289291

290292
def delete_cookie(self, key, path="/", domain=None, samesite=None):

docs/ref/request-response.txt

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,14 @@ Methods
395395
no longer valid. If you provide the ``default`` argument the exception
396396
will be suppressed and that default value will be returned instead.
397397

398-
The optional ``salt`` argument can be used to provide extra protection
399-
against brute force attacks on your secret key. If supplied, the
400-
``max_age`` argument will be checked against the signed timestamp
401-
attached to the cookie value to ensure the cookie is not older than
402-
``max_age`` seconds.
398+
The optional ``salt`` argument can be used to put the cookie into a
399+
separate signature namespace. If supplied, the ``max_age`` argument will
400+
be checked against the signed timestamp attached to the cookie value to
401+
ensure the cookie is not older than ``max_age`` seconds.
402+
403+
Cookies signed by older Django versions are accepted by default for
404+
backwards compatibility. Set :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK`
405+
to ``False`` to reject them.
403406

404407
For example:
405408

@@ -422,6 +425,11 @@ Methods
422425

423426
See :doc:`cryptographic signing </topics/signing>` for more information.
424427

428+
.. versionchanged:: 5.2.15
429+
430+
In older versions, cookies signed with distinct ``(key, salt)`` pairs
431+
that concatenate to the same string could be used interchangeably.
432+
425433
.. method:: HttpRequest.is_secure()
426434

427435
Returns ``True`` if the request is secure; that is, if it was made with
@@ -1046,8 +1054,9 @@ Methods
10461054
Like :meth:`~HttpResponse.set_cookie`, but
10471055
:doc:`cryptographic signing </topics/signing>` the cookie before setting
10481056
it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`.
1049-
You can use the optional ``salt`` argument for added key strength, but
1050-
you will need to remember to pass it to the corresponding
1057+
You can use the optional ``salt`` argument to put the cookie into a
1058+
separate signature namespace, but you will need to remember to pass it to
1059+
the corresponding
10511060
:meth:`HttpRequest.get_signed_cookie` call.
10521061

10531062
.. method:: HttpResponse.delete_cookie(key, path='/', domain=None, samesite=None)

docs/ref/settings.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,6 +2698,24 @@ precedence and will be applied instead. See
26982698

26992699
See also :setting:`DATE_FORMAT` and :setting:`SHORT_DATE_FORMAT`.
27002700

2701+
.. setting:: SIGNED_COOKIE_LEGACY_SALT_FALLBACK
2702+
2703+
``SIGNED_COOKIE_LEGACY_SALT_FALLBACK``
2704+
---------------------------------------
2705+
2706+
.. versionadded:: 5.2.15
2707+
2708+
Default: ``True``
2709+
2710+
Controls whether :meth:`~django.http.HttpRequest.get_signed_cookie` accepts
2711+
cookies signed with Django's historical signed-cookie salt derivation based on
2712+
``key + salt``.
2713+
2714+
Set this to ``False`` to reject those legacy signed cookies and only accept
2715+
cookies signed with Django's current unambiguous signed-cookie salt derivation.
2716+
This transitional setting will be removed in Django 7.0, when the legacy signed
2717+
cookies will no longer be accepted.
2718+
27012719
.. setting:: SIGNING_BACKEND
27022720

27032721
``SIGNING_BACKEND``
@@ -3931,6 +3949,7 @@ HTTP
39313949
* :setting:`SECURE_REFERRER_POLICY`
39323950
* :setting:`SECURE_SSL_HOST`
39333951
* :setting:`SECURE_SSL_REDIRECT`
3952+
* :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK`
39343953
* :setting:`SIGNING_BACKEND`
39353954
* :setting:`USE_X_FORWARDED_HOST`
39363955
* :setting:`USE_X_FORWARDED_PORT`

docs/releases/5.2.15.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,20 @@ Django 5.2.15 release notes
55
*June 3, 2026*
66

77
Django 5.2.15 fixes five security issues with severity "low" in 5.2.14.
8+
9+
CVE-2026-6873: Signed cookie salt namespace collision
10+
=====================================================
11+
12+
:meth:`~django.http.HttpRequest.get_signed_cookie` derived the signing salt by
13+
concatenating the cookie name (``key``) and ``salt`` arguments. When distinct
14+
name and salt pairs produced the same concatenation, cookies could be accepted
15+
in a context different from the one where they were signed.
16+
17+
Cookies are now signed with an unambiguous salt derivation. For backwards
18+
compatibility, cookies signed by older Django versions are accepted until
19+
Django 7.0. Projects affected by the above ambiguity should set
20+
:setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` to ``False`` to reject older
21+
cookies immediately.
22+
23+
This issue has severity "low" according to the :ref:`Django security policy
24+
<severity-levels>`.

docs/releases/6.0.6.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ Django 6.0.6 release notes
77
Django 6.0.6 fixes five security issues with severity "low" and one bug in
88
6.0.5.
99

10+
CVE-2026-6873: Signed cookie salt namespace collision
11+
=====================================================
12+
13+
:meth:`~django.http.HttpRequest.get_signed_cookie` derived the signing salt by
14+
concatenating the cookie name (``key``) and ``salt`` arguments. When distinct
15+
name and salt pairs produced the same concatenation, cookies could be accepted
16+
in a context different from the one where they were signed.
17+
18+
Cookies are now signed with an unambiguous salt derivation. For backwards
19+
compatibility, cookies signed by older Django versions are accepted until
20+
Django 7.0. Projects affected by the above ambiguity should set
21+
:setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` to ``False`` to reject older
22+
cookies immediately.
23+
24+
This issue has severity "low" according to the :ref:`Django security policy
25+
<severity-levels>`.
26+
1027
Bugfixes
1128
========
1229

tests/signed_cookies_tests/tests.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.test.utils import freeze_time
77

88

9+
@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=False)
910
class SignedCookieTest(SimpleTestCase):
1011
def test_can_set_and_read_signed_cookies(self):
1112
response = HttpResponse()
@@ -27,6 +28,55 @@ def test_can_use_salt(self):
2728
with self.assertRaises(signing.BadSignature):
2829
request.get_signed_cookie("a", salt="two")
2930

31+
def test_salt_namespace_is_unambiguous(self):
32+
response = HttpResponse()
33+
response.set_signed_cookie("a", "hello", salt="bc")
34+
request = HttpRequest()
35+
request.COOKIES["ab"] = response.cookies["a"].value
36+
with self.assertRaises(signing.BadSignature):
37+
request.get_signed_cookie("ab", salt="c")
38+
39+
@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True)
40+
def test_expired_legacy_cookie_raises_signature_expired(self):
41+
with freeze_time(123456789):
42+
request = HttpRequest()
43+
request.COOKIES["a"] = signing.get_cookie_signer(
44+
salt=signing._cookie_signer_legacy_salt("a", "bc")
45+
).sign("hello")
46+
with freeze_time(123456800):
47+
with self.assertRaises(signing.SignatureExpired):
48+
request.get_signed_cookie("a", salt="bc", max_age=10)
49+
50+
@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True)
51+
def test_legacy_salt_namespace_is_accepted_by_default(self):
52+
request = HttpRequest()
53+
# Simulate an attack along the lines of CVE-2026-6873, where a value
54+
# for the "a" cookie is submitted as the value for another cookie.
55+
request.COOKIES["ab"] = signing.get_cookie_signer(
56+
salt=signing._cookie_signer_legacy_salt("a", "bc")
57+
).sign("hello")
58+
# No protection since SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True.
59+
self.assertEqual(request.get_signed_cookie("ab", salt="c"), "hello")
60+
61+
def test_legacy_salt_namespace_not_accepted(self):
62+
request = HttpRequest()
63+
request.COOKIES["a"] = signing.get_cookie_signer(
64+
salt=signing._cookie_signer_legacy_salt("a", "bc")
65+
).sign("hello")
66+
with self.assertRaises(signing.BadSignature):
67+
request.get_signed_cookie("a", salt="bc")
68+
69+
@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True)
70+
def test_expired_new_style_cookie_does_not_fallback_to_legacy_salt(self):
71+
with freeze_time(123456789):
72+
response = HttpResponse()
73+
response.set_signed_cookie("a", "hello", salt="bc")
74+
request = HttpRequest()
75+
request.COOKIES["a"] = response.cookies["a"].value
76+
with freeze_time(123456800):
77+
with self.assertRaises(signing.SignatureExpired):
78+
request.get_signed_cookie("a", salt="bc", max_age=10)
79+
3080
def test_detects_tampering(self):
3181
response = HttpResponse()
3282
response.set_signed_cookie("c", "hello")

0 commit comments

Comments
 (0)