From 9dda244bc6485d2c4eaea75793d3b51d682a8ae9 Mon Sep 17 00:00:00 2001
From: Luke Plant <L.Plant.98@cantab.net>
Date: Fri, 18 Feb 2022 12:26:41 +0000
Subject: [PATCH] Improved docs for Signer to encourage more secure patterns.

---
 django/core/signing.py        |  8 +--
 docs/ref/request-response.txt | 17 ++++---
 docs/topics/signing.txt       | 96 +++++++++++++++++++++++++++++------
 3 files changed, 94 insertions(+), 27 deletions(-)

diff --git a/django/core/signing.py b/django/core/signing.py
index 6e284f4c84..43bdc2efd5 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -3,28 +3,28 @@
 
 The format used looks like this:
 
->>> signing.dumps("hello")
+>>> signing.dumps("hello", salt="purpose1")
 'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'
 
 There are two components here, separated by a ':'. The first component is a
 URLsafe base64 encoded JSON of the object passed to dumps(). The second
 component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret"
 
 signing.loads(s) checks the signature and returns the deserialized object.
 If the signature fails, a BadSignature exception is raised.
 
->>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk")
+>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk", salt="purpose1")
 'hello'
->>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified")
+>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified", salt="purpose1")
 ...
 BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does
 not match
 
 You can optionally compress the JSON prior to base64 encoding it to save
 space, using the compress=True argument. This checks if compression actually
 helps and only applies compression if the result is a shorter string:
 
->>> signing.dumps(list(range(1, 20)), compress=True)
+>>> signing.dumps(list(range(1, 20)), salt="purpose1", compress=True)
 '.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ'
 
 The fact that the string is compressed is signalled by the prefixed '.' at the
diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
index 5469562a2d..c39a2bef5d 100644
--- a/docs/ref/request-response.txt
+++ b/docs/ref/request-response.txt
@@ -1065,13 +1065,16 @@ Methods
 
 .. method:: HttpResponse.set_signed_cookie(key, value, salt='', max_age=None, expires=None, path='/', domain=None, secure=False, httponly=False, samesite=None)
 
-    Like :meth:`~HttpResponse.set_cookie`, but
-    :doc:`cryptographic signing </topics/signing>` the cookie before setting
-    it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`.
-    You can use the optional ``salt`` argument to put the cookie into a
-    separate signature namespace, but you will need to remember to pass it to
-    the corresponding
-    :meth:`HttpRequest.get_signed_cookie` call.
+    Like :meth:`~HttpResponse.set_cookie`, but uses :doc:`cryptographic
+    signing </topics/signing>` on the cookie value before setting it. Use in
+    conjunction with :meth:`HttpRequest.get_signed_cookie`.
+
+    The ``key`` is used to generate the signature, to stop attackers re-using a
+    value signed for one key as a valid value for a different key. In addition,
+    it is helpful to use a unique ``salt`` argument for every different purpose
+    you use signed cookies for. See :ref:`understanding-signing-salt` for more
+    details. You will need to remember to pass the same ``salt`` value to the
+    corresponding :meth:`HttpRequest.get_signed_cookie` call.
 
 .. method:: HttpResponse.delete_cookie(key, path='/', domain=None, samesite=None)
 
diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt
index 57370fa4bb..cd79107fff 100644
--- a/docs/topics/signing.txt
+++ b/docs/topics/signing.txt
@@ -120,44 +120,108 @@ generate signatures. You can use a different secret by passing it to the
     of additional values used to validate signed data, defaults to
     :setting:`SECRET_KEY_FALLBACKS`.
 
-Using the ``salt`` argument
----------------------------
+.. _understanding-signing-salt:
 
-If you do not wish for every occurrence of a particular string to have the same
-signature hash, you can use the optional ``salt`` argument to the ``Signer``
-class. Using a salt will seed the signing hash function with both the salt and
-your :setting:`SECRET_KEY`:
+Understanding and using the ``salt`` argument
+---------------------------------------------
+
+Every different purpose for which you use signing should either have a
+different secret (which is passed as the first argument to ``Signer`` and is
+equal to :setting:`SECRET_KEY` by default), or use a unique ``salt`` argument
+or both.
+
+Using a salt will seed the signing hash function with both the salt and
+your secret:
 
 .. code-block:: pycon
 
-    >>> signer = Signer()
+    >>> signer = Signer(salt="myproject.purpose1")
     >>> signer.sign("My string")
-    'My string:v9G-nxfz3iQGTXrePqYPlGvH79WTcIgj1QIQSUODTW0'
+    'My string:vcazRX-FXPKIWyPdTTVn1pD3S4C3afjW9WkQB2R3Z9A'
     >>> signer.sign_object({"message": "Hello!"})
-    'eyJtZXNzYWdlIjoiSGVsbG8hIn0:bzb48DBkB-bwLaCnUVB75r5VAPUEpzWJPrTb80JMIXM'
-    >>> signer = Signer(salt="extra")
+    'eyJtZXNzYWdlIjoiSGVsbG8hIn0:vbmnwn5TK03_JZ-4PR8IHeBPhWaY8nU5C0FOr_TVXnA'
+    >>> signer = Signer(salt="myproject.purpose2")
     >>> signer.sign("My string")
-    'My string:YMD-FR6rof3heDkFRffdmG4pXbAZSOtb-aQxg3vmmfc'
-    >>> signer.unsign("My string:YMD-FR6rof3heDkFRffdmG4pXbAZSOtb-aQxg3vmmfc")
+    'My string:62ZfWuX41GqG_BOKYQq4vYB58G5pYO0h8tvM0OyCbyk'
+    >>> signer.unsign("My string:62ZfWuX41GqG_BOKYQq4vYB58G5pYO0h8tvM0OyCbyk")
     'My string'
     >>> signer.sign_object({"message": "Hello!"})
-    'eyJtZXNzYWdlIjoiSGVsbG8hIn0:-UWSLCE-oUAHzhkHviYz3SOZYBjFKllEOyVZNuUtM-I'
+    'eyJtZXNzYWdlIjoiSGVsbG8hIn0:w-C2ISTdMDgvexMJlbslLXOp0lNVA1zQtOOyAkkvBlQ'
     >>> signer.unsign_object(
-    ...     "eyJtZXNzYWdlIjoiSGVsbG8hIn0:-UWSLCE-oUAHzhkHviYz3SOZYBjFKllEOyVZNuUtM-I"
+    ...     "eyJtZXNzYWdlIjoiSGVsbG8hIn0:w-C2ISTdMDgvexMJlbslLXOp0lNVA1zQtOOyAkkvBlQ"
     ... )
     {'message': 'Hello!'}
 
 Using salt in this way puts the different signatures into different
 namespaces. A signature that comes from one namespace (a particular salt
 value) cannot be used to validate the same plaintext string in a different
 namespace that is using a different salt setting. The result is to prevent an
 attacker from using a signed string generated in one place in the code as input
-to another piece of code that is generating (and verifying) signatures using a
-different salt.
+to another piece of code that is generating (and verifying) signatures for a
+different purpose or in a different context.
 
 Unlike your :setting:`SECRET_KEY`, your salt argument does not need to stay
 secret.
 
+In addition to using salt values, you should think carefully about the value
+you are signing, and how it might be re-used by an attacker.
+
+For example, suppose you are an insurance company, and give a user a quote for
+home insurance of $20/month. You send the user a signed value generated as
+follows, perhaps stored in a cookie:
+
+.. code-block:: pycon
+
+    >>> signer = Signer(salt="acmeinsurance.quotes")
+    >>> signer.sign("20")
+
+
+There are multiple issues with this:
+
+1. It has no time limit or timestamp, so the value can be used forever.
+
+2. We are simply signing the string ``"20"`` which is very ambiguous in
+   meaning. You might also sell car insurance, and an attacker would be able to
+   take this signed value that was intended for home insurance and re-use it to
+   get cheap car insurance. Or they might re-use it for a completely different
+   house, or re-use it in a context where it means a yearly amount, rather than
+   a monthly amount.
+
+3. There is no scoping to the user. The user could take this signed value and
+   share it with a friend, who would also be able to use it on your site.
+
+The answers to these problems are:
+
+- Use :class:`~TimestampSigner`, or build an expiration timestamp into the
+  value that you sign, which you check later.
+
+- Use a more specific salt.
+
+- Instead of signing simple values without context, sign complex objects that
+  express more completely what you want to sign - “we offer the user with email
+  address ``user@example.com`` home insurance for 123 Main St, for the price of
+  20 dollars a month, valid until 2022-05-01”:
+
+  .. code-block:: pycon
+
+      >>> signer = Signer(salt="acmeinsurance.quotes.home_insurance")
+      >>> signer.sign_object(
+      ...     {
+      ...         "user_email": "user@example.com",
+      ...         "address": "123 Main St, 9980-999",
+      ...         "amount": 20,
+      ...         "unit": "dollars",
+      ...         "period": "month",
+      ...         "valid_until": "2022-05-01",
+      ...     }
+      ... )
+
+- Make sure you use or check each part of the value returned after calling
+  ``unsign``, rather than assuming it matches what you expect.
+
+- Instead of doing this at all, store the values in the database and give
+  the user just a reference.
+
 Verifying timestamped values
 ----------------------------
 
-- 
2.43.0

