Fixed #32191 -- Made CookieStorage use RFC 6265 compliant format.
Co-authored-by: Craig Smith <hello@craigiansmith.com.au>
This commit is contained in:
parent
3eb98743dc
commit
2d6179c819
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue