Fixed #27775 -- Added support for custom expiry in signed cookies.

This commit is contained in:
Abe Hanoka 2025-03-15 23:15:21 -04:00 committed by Sarah Boyce
parent ab1b9cc1b3
commit 126f98dadd
6 changed files with 76 additions and 23 deletions

View file

@ -10,13 +10,18 @@ class SessionStore(SessionBase):
if signature fails.
"""
try:
return signing.loads(
payload_value, timestamp = signing.loads(
self.session_key,
serializer=self.serializer,
# This doesn't handle non-default expiry dates, see #19201
max_age=self.get_session_cookie_age(),
salt="django.contrib.sessions.backends.signed_cookies",
return_timestamp=True,
)
signing.check_signature_expiry(
timestamp, max_age=payload_value.get("_session_expiry")
)
return payload_value
except Exception:
# BadSignature, ValueError, or unpickling exceptions. If any of
# these happen, reset the session.

View file

@ -115,6 +115,17 @@ def get_cookie_signer(salt="django.core.signing.get_cookie_signer"):
)
def check_signature_expiry(timestamp, max_age=None):
if max_age is None:
return
if isinstance(max_age, datetime.timedelta):
max_age = max_age.total_seconds()
# Check timestamp is not older than max_age.
age = time.time() - timestamp
if age > max_age:
raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))
class JSONSerializer:
"""
Simple wrapper around json to be used in signing.dumps and
@ -159,6 +170,7 @@ def loads(
serializer=JSONSerializer,
max_age=None,
fallback_keys=None,
return_timestamp=False,
):
"""
Reverse of dumps(), raise BadSignature if signature fails.
@ -171,6 +183,7 @@ def loads(
s,
serializer=serializer,
max_age=max_age,
return_timestamp=return_timestamp,
)
@ -259,19 +272,22 @@ class TimestampSigner(Signer):
value = "%s%s%s" % (value, self.sep, self.timestamp())
return super().sign(value)
def unsign(self, value, max_age=None):
def unsign(self, value, max_age=None, return_timestamp=False):
"""
Retrieve original value and check it wasn't signed more
than max_age seconds ago.
"""
result = super().unsign(value)
value, timestamp = result.rsplit(self.sep, 1)
timestamp = b62_decode(timestamp)
if max_age is not None:
if isinstance(max_age, datetime.timedelta):
max_age = max_age.total_seconds()
# Check timestamp is not older than max_age
age = time.time() - timestamp
if age > max_age:
raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))
self.payload_timestamp = b62_decode(timestamp)
check_signature_expiry(self.payload_timestamp, max_age=max_age)
if return_timestamp:
return value, self.payload_timestamp
return value
def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, **kwargs):
# First verify the outer max_age boundary
value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
if return_timestamp:
return value, self.payload_timestamp
return value

View file

@ -217,7 +217,11 @@ Requests and Responses
Security
~~~~~~~~
* ...
* The new ``return_timestamp`` parameter for
:func:`~django.core.signing.loads`,
:meth:`~django.core.signing.TimestampSigner.unsign`, and
:meth:`~django.core.signing.TimestampSigner.unsign_object` determines whether
a tuple containing the unsigned object and the timestamp is returned.
Serialization
~~~~~~~~~~~~~

View file

@ -188,23 +188,37 @@ created within a specified period of time:
Sign ``value`` and append current timestamp to it.
.. method:: unsign(value, max_age=None)
.. method:: unsign(value, max_age=None, return_timestamp=False)
Checks if ``value`` 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.
If ``return_timestamp`` is ``True``, returns a tuple of the unsigned
value and the timestamp, otherwise the unsigned value is returned.
.. versionchanged:: 6.0
The ``return_timestamp`` parameter was added.
.. method:: sign_object(obj, serializer=JSONSerializer, compress=False)
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)
.. method:: unsign_object(signed_obj, serializer=JSONSerializer, max_age=None, return_timestamp=False)
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.
If ``return_timestamp`` is ``True``, returns a tuple of the unsigned
object and the timestamp, otherwise the unsigned object is returned.
.. versionchanged:: 6.0
The ``return_timestamp`` parameter was added.
.. _signing-complex-data:
Protecting complex data structures
@ -249,7 +263,14 @@ and tuples) if you pass in a tuple, you will get a list from
Returns URL-safe, signed base64 compressed JSON string. Serialized object
is signed using :class:`~TimestampSigner`.
.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None, fallback_keys=None)
.. function:: loads(string, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None, fallback_keys=None, return_timestamp=False)
Reverse of ``dumps()``, raises ``BadSignature`` if signature fails.
Checks ``max_age`` (in seconds) if given.
If ``return_timestamp`` is ``True``, returns a tuple of the unsigned object
and the timestamp, otherwise the unsigned value is returned.
.. versionchanged:: 6.0
The ``return_timestamp`` parameter was added.

View file

@ -1245,14 +1245,6 @@ class CookieSessionTests(SessionTestsMixin, SimpleTestCase):
async def test_cycle_async(self):
pass
@unittest.expectedFailure
def test_actual_expiry(self):
# The cookie backend doesn't handle non-default expiry dates, see #19201
super().test_actual_expiry()
async def test_actual_expiry_async(self):
pass
def test_unpickling_exception(self):
# signed_cookies backend should handle unpickle exceptions gracefully
# by creating a new session

View file

@ -233,6 +233,21 @@ class TestTimestampSigner(SimpleTestCase):
with self.assertRaises(signing.SignatureExpired):
signer.unsign(ts, max_age=10)
def test_return_timestamp(self):
value = "hello"
with freeze_time(123456888):
signer = signing.TimestampSigner(key="predictable-key")
signed_obj = signer.sign_object(value)
data_and_time = signer.unsign_object(signed_obj, return_timestamp=True)
self.assertEqual(data_and_time, (value, 123456888))
def test_return_timestamp_dump_loads(self):
value = {"foo": "bar", "baz": 42}
with freeze_time(123456789):
signed_data = signing.dumps(value)
data_and_time = signing.loads(signed_data, return_timestamp=True)
self.assertEqual(data_and_time, (value, 123456789))
class TestBase62(SimpleTestCase):
def test_base62(self):