티스토리 수익 글 보기

티스토리 수익 글 보기

[6.1.x] Fixed CVE-2026-6873 — Prevented signed cookie salt namespace… · django/django@42bdfd7 · GitHub
Skip to content

Commit 42bdfd7

Browse files
PaulMcMillannessita
authored andcommitted
[6.1.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 fcbbb1f commit 42bdfd7

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
@@ -561,6 +561,7 @@ def gettext_noop(s):
561561
# SIGNING #
562562
###########
563563

564+
SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True
564565
SIGNING_BACKEND = "django.core.signing.TimestampSigner"
565566

566567
########

django/core/signing.py

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

121121

122+
def _cookie_signer_salt(cookie_name, salt=""):
123+
# Prefix the salt length so (cookie_name, salt) pairs can't collide.
124+
return f"django.http.cookies.v2:{len(salt)}:{salt}{cookie_name}"
125+
126+
127+
def _cookie_signer_legacy_salt(cookie_name, salt=""):
128+
return cookie_name + salt
129+
130+
131+
def _unsign_cookie(signed_value, *, cookie_name, salt="", max_age=None):
132+
try:
133+
return get_cookie_signer(salt=_cookie_signer_salt(cookie_name, salt)).unsign(
134+
signed_value, max_age=max_age
135+
)
136+
except BadSignature as exc:
137+
if settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK and not isinstance(
138+
exc, SignatureExpired
139+
):
140+
return get_cookie_signer(
141+
salt=_cookie_signer_legacy_salt(cookie_name, salt)
142+
).unsign(signed_value, max_age=max_age)
143+
raise
144+
145+
122146
def get_cookie_signer(salt="django.core.signing.get_cookie_signer"):
123147
Signer = import_string(settings.SIGNING_BACKEND)
124148
return Signer(

django/http/request.py

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

django/http/response.py

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

289289
def set_signed_cookie(self, key, value, salt="", **kwargs):
290-
value = signing.get_cookie_signer(salt=key + salt).sign(value)
290+
value = signing.get_cookie_signer(
291+
salt=signing._cookie_signer_salt(key, salt)
292+
).sign(value)
291293
return self.set_cookie(key, value, **kwargs)
292294

293295
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
@@ -418,11 +418,14 @@ Methods
418418
no longer valid. If you provide the ``default`` argument the exception
419419
will be suppressed and that default value will be returned instead.
420420

421-
The optional ``salt`` argument can be used to provide extra protection
422-
against brute force attacks on your secret key. If supplied, the
423-
``max_age`` argument will be checked against the signed timestamp
424-
attached to the cookie value to ensure the cookie is not older than
425-
``max_age`` seconds.
421+
The optional ``salt`` argument can be used to put the cookie into a
422+
separate signature namespace. If supplied, the ``max_age`` argument will
423+
be checked against the signed timestamp attached to the cookie value to
424+
ensure the cookie is not older than ``max_age`` seconds.
425+
426+
Cookies signed by older Django versions are accepted by default for
427+
backwards compatibility. Set :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK`
428+
to ``False`` to reject them.
426429

427430
For example:
428431

@@ -445,6 +448,11 @@ Methods
445448

446449
See :doc:`cryptographic signing </topics/signing>` for more information.
447450

451+
.. versionchanged:: 5.2.15
452+
453+
In older versions, cookies signed with distinct ``(key, salt)`` pairs
454+
that concatenate to the same string could be used interchangeably.
455+
448456
.. method:: HttpRequest.is_secure()
449457

450458
Returns ``True`` if the request is secure; that is, if it was made with
@@ -1065,8 +1073,9 @@ Methods
10651073
Like :meth:`~HttpResponse.set_cookie`, but
10661074
:doc:`cryptographic signing </topics/signing>` the cookie before setting
10671075
it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`.
1068-
You can use the optional ``salt`` argument for added key strength, but
1069-
you will need to remember to pass it to the corresponding
1076+
You can use the optional ``salt`` argument to put the cookie into a
1077+
separate signature namespace, but you will need to remember to pass it to
1078+
the corresponding
10701079
:meth:`HttpRequest.get_signed_cookie` call.
10711080

10721081
.. 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
@@ -2848,6 +2848,24 @@ precedence and will be applied instead. See
28482848

28492849
See also :setting:`DATE_FORMAT` and :setting:`SHORT_DATE_FORMAT`.
28502850

2851+
.. setting:: SIGNED_COOKIE_LEGACY_SALT_FALLBACK
2852+
2853+
``SIGNED_COOKIE_LEGACY_SALT_FALLBACK``
2854+
---------------------------------------
2855+
2856+
.. versionadded:: 5.2.15
2857+
2858+
Default: ``True``
2859+
2860+
Controls whether :meth:`~django.http.HttpRequest.get_signed_cookie` accepts
2861+
cookies signed with Django's historical signed-cookie salt derivation based on
2862+
``key + salt``.
2863+
2864+
Set this to ``False`` to reject those legacy signed cookies and only accept
2865+
cookies signed with Django's current unambiguous signed-cookie salt derivation.
2866+
This transitional setting will be removed in Django 7.0, when the legacy signed
2867+
cookies will no longer be accepted.
2868+
28512869
.. setting:: SIGNING_BACKEND
28522870

28532871
``SIGNING_BACKEND``
@@ -4101,6 +4119,7 @@ HTTP
41014119
* :setting:`SECURE_REFERRER_POLICY`
41024120
* :setting:`SECURE_SSL_HOST`
41034121
* :setting:`SECURE_SSL_REDIRECT`
4122+
* :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK`
41044123
* :setting:`SIGNING_BACKEND`
41054124
* :setting:`USE_X_FORWARDED_HOST`
41064125
* :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)