diff --git a/django/contrib/messages/storage/cookie.py b/django/contrib/messages/storage/cookie.py index 1198689968..30689dde3b 100644 --- a/django/contrib/messages/storage/cookie.py +++ b/django/contrib/messages/storage/cookie.py @@ -14,10 +14,6 @@ class MessageEncoder(json.JSONEncoder): """ message_key = '__json_message' - def __init__(self, *args, **kwargs): - kwargs.setdefault('separators', (',', ':')) - super().__init__(*args, **kwargs) - def default(self, obj): if isinstance(obj, Message): # Using 0/1 here instead of False/True to produce more compact json @@ -51,6 +47,18 @@ class MessageDecoder(json.JSONDecoder): return self.process_messages(decoded) +class MessageSerializer: + def dumps(self, obj): + return json.dumps( + obj, + separators=(',', ':'), + cls=MessageEncoder, + ).encode('latin-1') + + def loads(self, data): + return json.loads(data.decode('latin-1'), cls=MessageDecoder) + + class CookieStorage(BaseStorage): """ Store messages in a cookie. @@ -152,9 +160,7 @@ class CookieStorage(BaseStorage): also contains a hash to ensure that the data was not tampered with. """ if messages or encode_empty: - encoder = MessageEncoder() - value = encoder.encode(messages) - return self.signer.sign(value) + return self.signer.sign_object(messages, serializer=MessageSerializer, compress=True) def _decode(self, data): """ @@ -166,13 +172,21 @@ class CookieStorage(BaseStorage): if not data: return None try: - decoded = self.signer.unsign(data) + return self.signer.unsign_object(data, serializer=MessageSerializer) + # RemovedInDjango41Warning: when the deprecation ends, replace with: + # + # except (signing.BadSignature, json.JSONDecodeError): + # pass except signing.BadSignature: # RemovedInDjango40Warning: when the deprecation ends, replace # with: # decoded = None. decoded = self._legacy_decode(data) + except json.JSONDecodeError: + decoded = self.signer.unsign(data) + if decoded: + # RemovedInDjango41Warning. try: return json.loads(decoded, cls=MessageDecoder) except json.JSONDecodeError: diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index b53552bb0a..d0d9c45f7e 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -31,6 +31,9 @@ details on these changes. * ``django.core.cache.backends.memcached.MemcachedCache`` will be removed. +* Support for the pre-Django 3.2 format of messages used by + ``django.contrib.messages.storage.cookie.CookieStorage`` will be removed. + .. _deprecation-removed-in-4.0: 4.0 diff --git a/docs/ref/contrib/messages.txt b/docs/ref/contrib/messages.txt index 2f05ce7370..ec8032eb10 100644 --- a/docs/ref/contrib/messages.txt +++ b/docs/ref/contrib/messages.txt @@ -69,6 +69,10 @@ Django provides three built-in storage classes in to prevent manipulation) to persist notifications across requests. Old messages are dropped if the cookie data size would exceed 2048 bytes. + .. versionchanged:: 3.2 + + Messages format was changed to the :rfc:`6265` compliant format. + .. class:: storage.fallback.FallbackStorage This class first uses ``CookieStorage``, and falls back to using diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 6850774312..a2aa1bb80c 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -702,6 +702,10 @@ Miscellaneous * The minimum supported version of SQLite is increased from 3.8.3 to 3.9.0. +* :class:`~django.contrib.messages.storage.cookie.CookieStorage` now stores + messages in the :rfc:`6265` compliant format. Support for cookies that use + the old format remains until Django 4.1. + .. _deprecated-features-3.2: Features deprecated in 3.2 @@ -737,3 +741,8 @@ Miscellaneous deprecated as ``python-memcached`` has some problems and seems to be unmaintained. Use ``django.core.cache.backends.memcached.PyMemcacheCache`` or ``django.core.cache.backends.memcached.PyLibMCCache`` instead. + +* The format of messages used by + ``django.contrib.messages.storage.cookie.CookieStorage`` is different from + the format generated by older versions of Django. Support for the old format + remains until Django 4.1. diff --git a/tests/messages_tests/test_cookie.py b/tests/messages_tests/test_cookie.py index 7f4672de8b..9f82ce93e9 100644 --- a/tests/messages_tests/test_cookie.py +++ b/tests/messages_tests/test_cookie.py @@ -1,4 +1,5 @@ import json +import random from django.conf import settings from django.contrib.messages import constants @@ -6,8 +7,10 @@ from django.contrib.messages.storage.base import Message from django.contrib.messages.storage.cookie import ( CookieStorage, MessageDecoder, MessageEncoder, ) +from django.core.signing import get_cookie_signer from django.test import SimpleTestCase, override_settings from django.test.utils import ignore_warnings +from django.utils.crypto import get_random_string from django.utils.deprecation import RemovedInDjango40Warning from django.utils.safestring import SafeData, mark_safe @@ -71,7 +74,9 @@ class CookieTests(BaseTests, SimpleTestCase): response = self.get_response() storage.add(constants.INFO, 'test') storage.update(response) - self.assertIn('test', response.cookies['messages'].value) + messages = storage._decode(response.cookies['messages'].value) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].message, 'test') self.assertEqual(response.cookies['messages']['domain'], '.example.com') self.assertEqual(response.cookies['messages']['expires'], '') self.assertIs(response.cookies['messages']['secure'], True) @@ -116,15 +121,30 @@ class CookieTests(BaseTests, SimpleTestCase): # size which will fit 4 messages into the cookie, but not 5. # See also FallbackTest.test_session_fallback msg_size = int((CookieStorage.max_cookie_size - 54) / 4.5 - 37) + first_msg = None + # Generate the same (tested) content every time that does not get run + # through zlib compression. + random.seed(42) for i in range(5): - storage.add(constants.INFO, str(i) * msg_size) + msg = get_random_string(msg_size) + storage.add(constants.INFO, msg) + if i == 0: + first_msg = msg unstored_messages = storage.update(response) cookie_storing = self.stored_messages_count(storage, response) self.assertEqual(cookie_storing, 4) self.assertEqual(len(unstored_messages), 1) - self.assertEqual(unstored_messages[0].message, '0' * msg_size) + self.assertEqual(unstored_messages[0].message, first_msg) + + def test_message_rfc6265(self): + non_compliant_chars = ['\\', ',', ';', '"'] + messages = ['\\te,st', ';m"e', '\u2019', '123"NOTRECEIVED"'] + storage = self.get_storage() + encoded = storage._encode(messages) + for illegal in non_compliant_chars: + self.assertEqual(encoded.find(illegal), -1) def test_json_encoder_decoder(self): """ @@ -172,6 +192,19 @@ class CookieTests(BaseTests, SimpleTestCase): decoded_messages = storage._decode(encoded_messages) self.assertEqual(messages, decoded_messages) + def test_legacy_encode_decode(self): + # RemovedInDjango41Warning: pre-Django 3.2 encoded messages will be + # invalid. + storage = self.storage_class(self.get_request()) + messages = ['this', 'that'] + # Encode/decode a message using the pre-Django 3.2 format. + encoder = MessageEncoder() + value = encoder.encode(messages) + signer = get_cookie_signer(salt=storage.key_salt) + encoded_messages = signer.sign(value) + decoded_messages = storage._decode(encoded_messages) + self.assertEqual(messages, decoded_messages) + @ignore_warnings(category=RemovedInDjango40Warning) def test_default_hashing_algorithm(self): messages = Message(constants.DEBUG, ['this', 'that']) diff --git a/tests/messages_tests/test_fallback.py b/tests/messages_tests/test_fallback.py index ea39f32f32..dcfc53d8cb 100644 --- a/tests/messages_tests/test_fallback.py +++ b/tests/messages_tests/test_fallback.py @@ -1,8 +1,11 @@ +import random + from django.contrib.messages import constants from django.contrib.messages.storage.fallback import ( CookieStorage, FallbackStorage, ) from django.test import SimpleTestCase +from django.utils.crypto import get_random_string from .base import BaseTests from .test_cookie import set_cookie_data, stored_cookie_messages_count @@ -128,8 +131,11 @@ class FallbackTests(BaseTests, SimpleTestCase): response = self.get_response() # see comment in CookieTests.test_cookie_max_length() msg_size = int((CookieStorage.max_cookie_size - 54) / 4.5 - 37) + # Generate the same (tested) content every time that does not get run + # through zlib compression. + random.seed(42) for i in range(5): - storage.add(constants.INFO, str(i) * msg_size) + storage.add(constants.INFO, get_random_string(msg_size)) storage.update(response) cookie_storing = self.stored_cookie_messages_count(storage, response) self.assertEqual(cookie_storing, 4) @@ -143,7 +149,10 @@ class FallbackTests(BaseTests, SimpleTestCase): """ storage = self.get_storage() response = self.get_response() - storage.add(constants.INFO, 'x' * 5000) + # Generate the same (tested) content every time that does not get run + # through zlib compression. + random.seed(42) + storage.add(constants.INFO, get_random_string(5000)) storage.update(response) cookie_storing = self.stored_cookie_messages_count(storage, response) self.assertEqual(cookie_storing, 0) diff --git a/tests/messages_tests/test_mixins.py b/tests/messages_tests/test_mixins.py index e102627089..051ed82b6b 100644 --- a/tests/messages_tests/test_mixins.py +++ b/tests/messages_tests/test_mixins.py @@ -1,3 +1,4 @@ +from django.core.signing import b64_decode from django.test import SimpleTestCase, override_settings from django.urls import reverse @@ -11,4 +12,8 @@ class SuccessMessageMixinTests(SimpleTestCase): author = {'name': 'John Doe', 'slug': 'success-msg'} add_url = reverse('add_success_msg') req = self.client.post(add_url, author) - self.assertIn(ContactFormViewWithMsg.success_message % author, req.cookies['messages'].value) + # Uncompressed message is stored in the cookie. + value = b64_decode( + req.cookies['messages'].value.split(":")[0].encode(), + ).decode() + self.assertIn(ContactFormViewWithMsg.success_message % author, value)