From 102d92fc09849e1a9004dd3f9a14a0ea9ca392cd Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Sat, 19 Dec 2020 14:21:12 +0100 Subject: [PATCH] Refs #32191 -- Added Signer.sign_object()/unsign_object(). Co-authored-by: Craig Smith --- django/core/signing.py | 66 ++++++++++++++++++++++++---------------- docs/releases/3.2.txt | 9 ++++++ docs/topics/signing.txt | 67 +++++++++++++++++++++++++++++++++++++---- tests/signing/tests.py | 16 ++++++++++ 4 files changed, 126 insertions(+), 32 deletions(-) diff --git a/django/core/signing.py b/django/core/signing.py index c6713c3033..a5bccfbdc8 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -107,21 +107,7 @@ def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, The serializer is expected to return a bytestring. """ - data = serializer().dumps(obj) - - # Flag for if it's been compressed or not - is_compressed = False - - if compress: - # Avoid zlib dependency unless compress is being used - compressed = zlib.compress(data) - if len(compressed) < (len(data) - 1): - data = compressed - is_compressed = True - base64d = b64_encode(data).decode() - if is_compressed: - base64d = '.' + base64d - return TimestampSigner(key, salt=salt).sign(base64d) + return TimestampSigner(key, salt=salt).sign_object(obj, serializer=serializer, compress=compress) def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None): @@ -130,17 +116,7 @@ def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, ma The serializer is expected to accept a bytestring. """ - # TimestampSigner.unsign() returns str but base64 and zlib compression - # operate on bytes. - base64d = TimestampSigner(key, salt=salt).unsign(s, max_age=max_age).encode() - decompress = base64d[:1] == b'.' - if decompress: - # It's compressed; uncompress it first - base64d = base64d[1:] - data = b64_decode(base64d) - if decompress: - data = zlib.decompress(data) - return serializer().loads(data) + return TimestampSigner(key, salt=salt).unsign_object(s, serializer=serializer, max_age=max_age) class Signer: @@ -183,6 +159,44 @@ class Signer: return value raise BadSignature('Signature "%s" does not match' % sig) + def sign_object(self, obj, serializer=JSONSerializer, compress=False): + """ + Return URL-safe, hmac signed base64 compressed JSON string. + + If compress is True (not the default), check if compressing using zlib + can save some space. Prepend a '.' to signify compression. This is + included in the signature, to protect against zip bombs. + + The serializer is expected to return a bytestring. + """ + data = serializer().dumps(obj) + # Flag for if it's been compressed or not. + is_compressed = False + + if compress: + # Avoid zlib dependency unless compress is being used. + compressed = zlib.compress(data) + if len(compressed) < (len(data) - 1): + data = compressed + is_compressed = True + base64d = b64_encode(data).decode() + if is_compressed: + base64d = '.' + base64d + return self.sign(base64d) + + def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs): + # Signer.unsign() returns str but base64 and zlib compression operate + # on bytes. + base64d = self.unsign(signed_obj, **kwargs).encode() + decompress = base64d[:1] == b'.' + if decompress: + # It's compressed; uncompress it first. + base64d = base64d[1:] + data = b64_decode(base64d) + if decompress: + data = zlib.decompress(data) + return serializer().loads(data) + class TimestampSigner(Signer): diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index aba6a6ccd4..71c5ec09ef 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -451,6 +451,15 @@ Security ``SECRET_KEY``, and then going on to access ``settings.SECRET_KEY`` will now raise an :exc:`~django.core.exceptions.ImproperlyConfigured` exception. +* The new ``Signer.sign_object()`` and ``Signer.unsign_object()`` methods allow + signing complex data structures. See :ref:`signing-complex-data` for more + details. + + Also, :func:`signing.dumps() ` and + :func:`~django.core.signing.loads` become shortcuts for + :meth:`.TimestampSigner.sign_object` and + :meth:`~.TimestampSigner.unsign_object`. + Serialization ~~~~~~~~~~~~~ diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt index d7d8f42728..2fc49ab747 100644 --- a/docs/topics/signing.txt +++ b/docs/topics/signing.txt @@ -62,6 +62,18 @@ value:: >>> original '2.5' +If you wish to protect a list, tuple, or dictionary you can do so using the +``sign_object()`` and ``unsign_object()`` methods:: + + >>> signed_obj = signer.sign_object({'message': 'Hello!'}) + >>> signed_obj + 'eyJtZXNzYWdlIjoiSGVsbG8hIn0:Xdc-mOFDjs22KsQAqfVfi8PQSPdo3ckWJxPWwQOFhR4' + >>> obj = signer.unsign_object(signed_obj) + >>> obj + {'message': 'Hello!'} + +See :ref:`signing-complex-data` for more details. + If the signature or value have been altered in any way, a ``django.core.signing.BadSignature`` exception will be raised:: @@ -93,6 +105,10 @@ generate signatures. You can use a different secret by passing it to the The ``algorithm`` parameter was added. +.. versionchanged:: 3.2 + + The ``sign_object()`` and ``unsign_object()`` methods were added. + Using the ``salt`` argument --------------------------- @@ -104,11 +120,17 @@ your :setting:`SECRET_KEY`:: >>> signer = Signer() >>> signer.sign('My string') 'My string:GdMGD6HNQ_qdgxYP8yBZAdAIV1w' + >>> signer.sign_object({'message': 'Hello!'}) + 'eyJtZXNzYWdlIjoiSGVsbG8hIn0:Xdc-mOFDjs22KsQAqfVfi8PQSPdo3ckWJxPWwQOFhR4' >>> signer = Signer(salt='extra') >>> signer.sign('My string') 'My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw' >>> signer.unsign('My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw') 'My string' + >>> signer.sign_object({'message': 'Hello!'}) + 'eyJtZXNzYWdlIjoiSGVsbG8hIn0:-UWSLCE-oUAHzhkHviYz3SOZYBjFKllEOyVZNuUtM-I' + >>> signer.unsign_object('eyJtZXNzYWdlIjoiSGVsbG8hIn0:-UWSLCE-oUAHzhkHviYz3SOZYBjFKllEOyVZNuUtM-I') + {'message': 'Hello!'} Using salt in this way puts the different signatures into different namespaces. A signature that comes from one namespace (a particular salt @@ -121,6 +143,10 @@ different salt. Unlike your :setting:`SECRET_KEY`, your salt argument does not need to stay secret. +.. versionchanged:: 3.2 + + The ``sign_object()`` and ``unsign_object()`` methods were added. + Verifying timestamped values ---------------------------- @@ -156,23 +182,48 @@ created within a specified period of time:: otherwise raises ``SignatureExpired``. The ``max_age`` parameter can accept an integer or a :py:class:`datetime.timedelta` object. + .. method:: sign_object(obj, serializer=JSONSerializer, compress=False) + + .. versionadded:: 3.2 + + Encode, optionally compress, append current timestamp, and sign complex + data structure (e.g. list, tuple, or dictionary). + + .. method:: unsign_object(signed_obj, serializer=JSONSerializer, max_age=None) + + .. versionadded:: 3.2 + + Checks if ``signed_obj`` was signed less than ``max_age`` seconds ago, + otherwise raises ``SignatureExpired``. The ``max_age`` parameter can + accept an integer or a :py:class:`datetime.timedelta` object. + .. versionchanged:: 3.1 The ``algorithm`` parameter was added. +.. _signing-complex-data: + Protecting complex data structures ---------------------------------- If you wish to protect a list, tuple or dictionary you can do so using the -signing module's ``dumps`` and ``loads`` functions. These imitate Python's -pickle module, but use JSON serialization under the hood. JSON ensures that -even if your :setting:`SECRET_KEY` is stolen an attacker will not be able -to execute arbitrary commands by exploiting the pickle format:: +``Signer.sign_object()`` and ``unsign_object()`` methods, or signing module's +``dumps()`` or ``loads()`` functions (which are shortcuts for +``TimestampSigner(salt='django.core.signing').sign_object()/unsign_object()``). +These use JSON serialization under the hood. JSON ensures that even if your +:setting:`SECRET_KEY` is stolen an attacker will not be able to execute +arbitrary commands by exploiting the pickle format:: >>> from django.core import signing - >>> value = signing.dumps({"foo": "bar"}) + >>> signer = signing.TimestampSigner() + >>> value = signer.sign_object({'foo': 'bar'}) >>> value - 'eyJmb28iOiJiYXIifQ:1NMg1b:zGcDE4-TCkaeGzLeW9UQwZesciI' + 'eyJmb28iOiJiYXIifQ:1kx6R3:D4qGKiptAqo5QW9iv4eNLc6xl4RwiFfes6oOcYhkYnc' + >>> signer.unsign_object(value) + {'foo': 'bar'} + >>> value = signing.dumps({'foo': 'bar'}) + >>> value + 'eyJmb28iOiJiYXIifQ:1kx6Rf:LBB39RQmME-SRvilheUe5EmPYRbuDBgQp2tCAi7KGLk' >>> signing.loads(value) {'foo': 'bar'} @@ -194,3 +245,7 @@ and tuples) if you pass in a tuple, you will get a list from Reverse of ``dumps()``, raises ``BadSignature`` if signature fails. Checks ``max_age`` (in seconds) if given. + +.. versionchanged:: 3.2 + + The ``sign_object()`` and ``unsign_object()`` methods were added. diff --git a/tests/signing/tests.py b/tests/signing/tests.py index 835ca4d6b2..50b2b0d9bb 100644 --- a/tests/signing/tests.py +++ b/tests/signing/tests.py @@ -122,6 +122,22 @@ class TestSigner(SimpleTestCase): with self.assertRaises(signing.BadSignature): signer.unsign(transform(signed_value)) + def test_sign_unsign_object(self): + signer = signing.Signer('predictable-secret') + tests = [ + ['a', 'list'], + 'a string \u2019', + {'a': 'dictionary'}, + ] + for obj in tests: + with self.subTest(obj=obj): + signed_obj = signer.sign_object(obj) + self.assertNotEqual(obj, signed_obj) + self.assertEqual(obj, signer.unsign_object(signed_obj)) + signed_obj = signer.sign_object(obj, compress=True) + self.assertNotEqual(obj, signed_obj) + self.assertEqual(obj, signer.unsign_object(signed_obj)) + def test_dumps_loads(self): "dumps and loads be reversible for any JSON serializable object" objects = [