티스토리 수익 글 보기

티스토리 수익 글 보기

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

Commit 594360c

Browse files
PaulMcMillannessita
authored andcommitted
[5.2.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 e074d83 commit 594360c

8 files changed

Lines changed: 132 additions & 10 deletions

File tree

django/conf/global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,7 @@ def gettext_noop(s):
555555
# SIGNING #
556556
###########
557557

558+
SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True
558559
SIGNING_BACKEND = "django.core.signing.TimestampSigner"
559560

560561
########

django/core/signing.py

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

108108

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

django/http/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,8 @@ def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None):
243243
else:
244244
raise
245245
try:
246-
value = signing.get_cookie_signer(salt=key + salt).unsign(
247-
cookie_value, max_age=max_age
246+
value = signing._unsign_cookie(
247+
cookie_value, cookie_name=key, salt=salt, max_age=max_age
248248
)
249249
except signing.BadSignature:
250250
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
@@ -393,11 +393,14 @@ Methods
393393
no longer valid. If you provide the ``default`` argument the exception
394394
will be suppressed and that default value will be returned instead.
395395

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

402405
For example:
403406

@@ -420,6 +423,11 @@ Methods
420423

421424
See :doc:`cryptographic signing </topics/signing>` for more information.
422425

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

425433
Returns ``True`` if the request is secure; that is, if it was made with
@@ -1043,8 +1051,9 @@ Methods
10431051
Like :meth:`~HttpResponse.set_cookie()`, but
10441052
:doc:`cryptographic signing </topics/signing>` the cookie before setting
10451053
it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`.
1046-
You can use the optional ``salt`` argument for added key strength, but
1047-
you will need to remember to pass it to the corresponding
1054+
You can use the optional ``salt`` argument to put the cookie into a
1055+
separate signature namespace, but you will need to remember to pass it to
1056+
the corresponding
10481057
:meth:`HttpRequest.get_signed_cookie` call.
10491058

10501059
.. 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
@@ -2596,6 +2596,24 @@ precedence and will be applied instead. See
25962596

25972597
See also :setting:`DATE_FORMAT` and :setting:`SHORT_DATE_FORMAT`.
25982598

2599+
.. setting:: SIGNED_COOKIE_LEGACY_SALT_FALLBACK
2600+
2601+
``SIGNED_COOKIE_LEGACY_SALT_FALLBACK``
2602+
---------------------------------------
2603+
2604+
.. versionadded:: 5.2.15
2605+
2606+
Default: ``True``
2607+
2608+
Controls whether :meth:`~django.http.HttpRequest.get_signed_cookie` accepts
2609+
cookies signed with Django's historical signed-cookie salt derivation based on
2610+
``key + salt``.
2611+
2612+
Set this to ``False`` to reject those legacy signed cookies and only accept
2613+
cookies signed with Django's current unambiguous signed-cookie salt derivation.
2614+
This transitional setting will be removed in Django 7.0, when the legacy signed
2615+
cookies will no longer be accepted.
2616+
25992617
.. setting:: SIGNING_BACKEND
26002618

26012619
``SIGNING_BACKEND``
@@ -3748,6 +3766,7 @@ HTTP
37483766
* :setting:`SECURE_REFERRER_POLICY`
37493767
* :setting:`SECURE_SSL_HOST`
37503768
* :setting:`SECURE_SSL_REDIRECT`
3769+
* :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK`
37513770
* :setting:`SIGNING_BACKEND`
37523771
* :setting:`USE_X_FORWARDED_HOST`
37533772
* :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>`.

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)