Browse Source

Fixed #32191 -- Made CookieStorage use RFC 6265 compliant format.

Co-authored-by: Craig Smith <hello@craigiansmith.com.au>
Florian Apolloner 4 years ago
parent
commit
2d6179c819

+ 22 - 8
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:

+ 3 - 0
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

+ 4 - 0
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

+ 9 - 0
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.

+ 36 - 3
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'])

+ 11 - 2
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)

+ 6 - 1
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)