summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Apolloner <florian@apolloner.eu>2020-12-19 14:21:12 +0100
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2021-01-06 20:16:47 +0100
commit102d92fc09849e1a9004dd3f9a14a0ea9ca392cd (patch)
treed1ac6b18640fc755d9248851aba16edb19e2f30e
parent5bcba16c0120d4a1bb661f0a495dc47f5ed5465d (diff)
Refs #32191 -- Added Signer.sign_object()/unsign_object().
Co-authored-by: Craig Smith <hello@craigiansmith.com.au>
-rw-r--r--django/core/signing.py66
-rw-r--r--docs/releases/3.2.txt9
-rw-r--r--docs/topics/signing.txt67
-rw-r--r--tests/signing/tests.py16
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() <django.core.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:1kx6R3:D4qGKiptAqo5QW9iv4eNLc6xl4RwiFfes6oOcYhkYnc'
+ >>> signer.unsign_object(value)
+ {'foo': 'bar'}
+ >>> value = signing.dumps({'foo': 'bar'})
>>> value
- 'eyJmb28iOiJiYXIifQ:1NMg1b:zGcDE4-TCkaeGzLeW9UQwZesciI'
+ '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 = [