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

Co-authored-by: Craig Smith <hello@craigiansmith.com.au>
This commit is contained in:
Florian Apolloner 2020-12-19 15:38:32 +01:00 committed by Mariusz Felisiak
parent 3eb98743dc
commit 2d6179c819
7 changed files with 91 additions and 14 deletions

View File

@ -14,10 +14,6 @@ class MessageEncoder(json.JSONEncoder):
""" """
message_key = '__json_message' message_key = '__json_message'
def __init__(self, *args, **kwargs):
kwargs.setdefault('separators', (',', ':'))
super().__init__(*args, **kwargs)
def default(self, obj): def default(self, obj):
if isinstance(obj, Message): if isinstance(obj, Message):
# Using 0/1 here instead of False/True to produce more compact json # 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) 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): class CookieStorage(BaseStorage):
""" """
Store messages in a cookie. 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. also contains a hash to ensure that the data was not tampered with.
""" """
if messages or encode_empty: if messages or encode_empty:
encoder = MessageEncoder() return self.signer.sign_object(messages, serializer=MessageSerializer, compress=True)
value = encoder.encode(messages)
return self.signer.sign(value)
def _decode(self, data): def _decode(self, data):
""" """
@ -166,13 +172,21 @@ class CookieStorage(BaseStorage):
if not data: if not data:
return None return None
try: 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: except signing.BadSignature:
# RemovedInDjango40Warning: when the deprecation ends, replace # RemovedInDjango40Warning: when the deprecation ends, replace
# with: # with:
# decoded = None. # decoded = None.
decoded = self._legacy_decode(data) decoded = self._legacy_decode(data)
except json.JSONDecodeError:
decoded = self.signer.unsign(data)
if decoded: if decoded:
# RemovedInDjango41Warning.
try: try:
return json.loads(decoded, cls=MessageDecoder) return json.loads(decoded, cls=MessageDecoder)
except json.JSONDecodeError: except json.JSONDecodeError:

View File

@ -31,6 +31,9 @@ details on these changes.
* ``django.core.cache.backends.memcached.MemcachedCache`` will be removed. * ``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: .. _deprecation-removed-in-4.0:
4.0 4.0

View File

@ -69,6 +69,10 @@ Django provides three built-in storage classes in
to prevent manipulation) to persist notifications across requests. Old to prevent manipulation) to persist notifications across requests. Old
messages are dropped if the cookie data size would exceed 2048 bytes. 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 .. class:: storage.fallback.FallbackStorage
This class first uses ``CookieStorage``, and falls back to using This class first uses ``CookieStorage``, and falls back to using

View File

@ -702,6 +702,10 @@ Miscellaneous
* The minimum supported version of SQLite is increased from 3.8.3 to 3.9.0. * 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: .. _deprecated-features-3.2:
Features deprecated in 3.2 Features deprecated in 3.2
@ -737,3 +741,8 @@ Miscellaneous
deprecated as ``python-memcached`` has some problems and seems to be deprecated as ``python-memcached`` has some problems and seems to be
unmaintained. Use ``django.core.cache.backends.memcached.PyMemcacheCache`` unmaintained. Use ``django.core.cache.backends.memcached.PyMemcacheCache``
or ``django.core.cache.backends.memcached.PyLibMCCache`` instead. 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.

View File

@ -1,4 +1,5 @@
import json import json
import random
from django.conf import settings from django.conf import settings
from django.contrib.messages import constants 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 ( from django.contrib.messages.storage.cookie import (
CookieStorage, MessageDecoder, MessageEncoder, CookieStorage, MessageDecoder, MessageEncoder,
) )
from django.core.signing import get_cookie_signer
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.test.utils import ignore_warnings from django.test.utils import ignore_warnings
from django.utils.crypto import get_random_string
from django.utils.deprecation import RemovedInDjango40Warning from django.utils.deprecation import RemovedInDjango40Warning
from django.utils.safestring import SafeData, mark_safe from django.utils.safestring import SafeData, mark_safe
@ -71,7 +74,9 @@ class CookieTests(BaseTests, SimpleTestCase):
response = self.get_response() response = self.get_response()
storage.add(constants.INFO, 'test') storage.add(constants.INFO, 'test')
storage.update(response) 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']['domain'], '.example.com')
self.assertEqual(response.cookies['messages']['expires'], '') self.assertEqual(response.cookies['messages']['expires'], '')
self.assertIs(response.cookies['messages']['secure'], True) 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. # size which will fit 4 messages into the cookie, but not 5.
# See also FallbackTest.test_session_fallback # See also FallbackTest.test_session_fallback
msg_size = int((CookieStorage.max_cookie_size - 54) / 4.5 - 37) 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): 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) unstored_messages = storage.update(response)
cookie_storing = self.stored_messages_count(storage, response) cookie_storing = self.stored_messages_count(storage, response)
self.assertEqual(cookie_storing, 4) self.assertEqual(cookie_storing, 4)
self.assertEqual(len(unstored_messages), 1) 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): def test_json_encoder_decoder(self):
""" """
@ -172,6 +192,19 @@ class CookieTests(BaseTests, SimpleTestCase):
decoded_messages = storage._decode(encoded_messages) decoded_messages = storage._decode(encoded_messages)
self.assertEqual(messages, decoded_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) @ignore_warnings(category=RemovedInDjango40Warning)
def test_default_hashing_algorithm(self): def test_default_hashing_algorithm(self):
messages = Message(constants.DEBUG, ['this', 'that']) messages = Message(constants.DEBUG, ['this', 'that'])

View File

@ -1,8 +1,11 @@
import random
from django.contrib.messages import constants from django.contrib.messages import constants
from django.contrib.messages.storage.fallback import ( from django.contrib.messages.storage.fallback import (
CookieStorage, FallbackStorage, CookieStorage, FallbackStorage,
) )
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.utils.crypto import get_random_string
from .base import BaseTests from .base import BaseTests
from .test_cookie import set_cookie_data, stored_cookie_messages_count from .test_cookie import set_cookie_data, stored_cookie_messages_count
@ -128,8 +131,11 @@ class FallbackTests(BaseTests, SimpleTestCase):
response = self.get_response() response = self.get_response()
# see comment in CookieTests.test_cookie_max_length() # see comment in CookieTests.test_cookie_max_length()
msg_size = int((CookieStorage.max_cookie_size - 54) / 4.5 - 37) 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): 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) storage.update(response)
cookie_storing = self.stored_cookie_messages_count(storage, response) cookie_storing = self.stored_cookie_messages_count(storage, response)
self.assertEqual(cookie_storing, 4) self.assertEqual(cookie_storing, 4)
@ -143,7 +149,10 @@ class FallbackTests(BaseTests, SimpleTestCase):
""" """
storage = self.get_storage() storage = self.get_storage()
response = self.get_response() 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) storage.update(response)
cookie_storing = self.stored_cookie_messages_count(storage, response) cookie_storing = self.stored_cookie_messages_count(storage, response)
self.assertEqual(cookie_storing, 0) self.assertEqual(cookie_storing, 0)

View File

@ -1,3 +1,4 @@
from django.core.signing import b64_decode
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.urls import reverse from django.urls import reverse
@ -11,4 +12,8 @@ class SuccessMessageMixinTests(SimpleTestCase):
author = {'name': 'John Doe', 'slug': 'success-msg'} author = {'name': 'John Doe', 'slug': 'success-msg'}
add_url = reverse('add_success_msg') add_url = reverse('add_success_msg')
req = self.client.post(add_url, author) 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)