Fixed #27775 -- Added support for custom expiry in signed cookies.#19191
Fixed #27775 -- Added support for custom expiry in signed cookies.#19191abe-101 wants to merge 1 commit intodjango:mainfrom
Conversation
There was a problem hiding this comment.
Hello! Thank you for your contribution 💪
As it's your first contribution be sure to check out the patch review checklist.
If you're fixing a ticket from Trac make sure to set the "Has patch" flag and include a link to this PR in the ticket!
If you have any design or process questions then you can ask in the Django forum.
Welcome aboard ⛵️!
django/core/signing.py
Outdated
There was a problem hiding this comment.
value here is allowed to be a "complex data structure (e.g. list, tuple, or dictionary)". Should support be added for non-dictionary types (perhaps supporting indexing?). Alternatively, should the error message be nicer if it is of the wrong type?
There was a problem hiding this comment.
Thanks for raising this point! You're right that we should handle non-dictionary types more gracefully.
Here's what I propose adding:
if not isinstance(value, dict):
raise TypeError(
f"expiration_key is only supported for dict values, not {type(value).__name__}"
)There was a problem hiding this comment.
is there is a more abstract key indexable type we can check for?
this way we can support more than just a dict
There was a problem hiding this comment.
👍 Nice catch! I hadn't thought that far to support more than just a regular dict. I think we can use Mapping.
We can also add tests for non-supported type input.
d794bde to
3688312
Compare
thank you @jacobtylerwalls for the review |
|
@sarahboyce can we add the Djangonaut label to this PR? |
sarahboyce
left a comment
There was a problem hiding this comment.
Thank you for the PR @abe-101 ⭐
Did you investigate this comment from Florian?
I think it might be interesting to reduce the signature changes, for example:
--- a/django/contrib/sessions/backends/signed_cookies.py
+++ b/django/contrib/sessions/backends/signed_cookies.py
@@ -15,7 +15,6 @@ class SessionStore(SessionBase):
serializer=self.serializer,
max_age=self.get_session_cookie_age(),
salt="django.contrib.sessions.backends.signed_cookies",
- expiration_key="_session_expiry",
)
except Exception:
# BadSignature, ValueError, or unpickling exceptions. If any of
diff --git a/django/core/signing.py b/django/core/signing.py
index 770ea1c8b5..9221167734 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -159,20 +159,21 @@ def loads(
serializer=JSONSerializer,
max_age=None,
fallback_keys=None,
- expiration_key=None,
):
"""
Reverse of dumps(), raise BadSignature if signature fails.
The serializer is expected to accept a bytestring.
"""
- return TimestampSigner(
- key=key,
- salt=salt,
- fallback_keys=fallback_keys,
- ).unsign_object(
- s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
+ signer = TimestampSigner(key=key, salt=salt, fallback_keys=fallback_keys)
+ unsigned_data = signer.unsign_object(
+ s,
+ serializer=serializer,
+ max_age=max_age,
)
+ if expiry := unsigned_data.get("_session_expiry"):
+ signer.verify_max_age(expiry)
+ return unsigned_data
class Signer:
@@ -253,6 +254,10 @@ class Signer:
class TimestampSigner(Signer):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.payload_timestamp = None
+
def timestamp(self):
return b62_encode(int(time.time()))
@@ -268,23 +273,11 @@ class TimestampSigner(Signer):
result = super().unsign(value)
value, timestamp = result.rsplit(self.sep, 1)
self.payload_timestamp = b62_decode(timestamp)
- self._verify_max_age(max_age)
- return value
-
- def unsign_object(self, signed_obj, max_age=None, expiration_key=None, **kwargs):
- # First verify the outer max_age boundary
- value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
-
- # Then if expiration_key is provided, check it as an additional constraint
- if expiration_key is not None:
- expiry = value.get(expiration_key)
- if expiry is not None:
- self._verify_max_age(expiry)
-
+ self.verify_max_age(max_age)
return value
- def _verify_max_age(self, max_age=None):
- if max_age is None:
+ def verify_max_age(self, max_age=None):
+ if max_age is None or self.payload_timestamp is None:
return
if isinstance(max_age, datetime.timedelta):
max_age = max_age.total_seconds()| return TimestampSigner( | ||
| key=key, salt=salt, fallback_keys=fallback_keys | ||
| key=key, | ||
| salt=salt, | ||
| fallback_keys=fallback_keys, | ||
| ).unsign_object( | ||
| s, | ||
| serializer=serializer, | ||
| max_age=max_age, | ||
| s, serializer=serializer, max_age=max_age, expiration_key=expiration_key | ||
| ) |
There was a problem hiding this comment.
Try and keep formatting consistent for an easier review
return TimestampSigner(
key=key, salt=salt, fallback_keys=fallback_keys
).unsign_object(
s,
serializer=serializer,
max_age=max_age,
expiration_key=expiration_key,
)
tests/sessions_tests/tests.py
Outdated
| ): | ||
| pass | ||
|
|
||
| def test_session_custom_expiry_load(self): |
There was a problem hiding this comment.
This will need a test for the async case
I argue that the checking for expiration ( Perhaps we should introduce a new method to |
@sarahboyce I'm confused by your suggestion to remove the line (from the and instead only support a predefined key ( my understanding is that the in this PR we add a way to specify a key to be used to lookup an expiration value (from within the signed obj) - to do that there needs to be a change to the signing module |
|
@sarahboyce I appreciate you review and i understand you hesitation to prevent signature changes - as it can be hard to know the consequences... Let me assure you that: I think can avoid changing the I don't see a way to avoid a change in the |
I think that's a nice idea 👍 |
ok - Exploring this option, where should the time validation happen?
anyone have any other suggestions? |
|
@abe-101 these look like reasonable options, and I can’t think of any others. Of the options you’ve listed, which one are you leaning toward? |
|
Is it a better option to change how django/django/contrib/sessions/backends/signed_cookies.py Lines 5 to 19 in ae2736c where instead of using signing.loads() it directly uses the signing.TimestampSigner classLike so: class SessionStore(SessionBase):
def load(self):
"""
Load the data from the key itself instead of fetching from some
external data store. Opposite of _get_session_key(), raise BadSignature
if signature fails.
"""
try:
signer = signing.TimestampSigner(
salt="django.contrib.sessions.backends.signed_cookies"
)
session_data = signer.unsign_object(
self.session_key,
serializer=self.serializer,
max_age=self.get_session_cookie_age(),
)
expiry = session_data.get("_session_expiry")
signer.verify_max_age(expiry)
return session_data
this approach only makes a small change to the signing module (refactoring I wonder if ther will be a side affect if not using |
|
My problem with this solution is that it creates a forked serialization path: The problem here is that if someone asks for a jsonified That said, I also believe this is the same implementation as here, but moved up one level, introducing the fork. Apologies if I got any of this wrong, it's been a long day but I couldn't stop thinking about this until I got this down :) |
Current ApproachThe current approach provides a public way to tell the signing module to check for an expiry key. One or more signatures must change for that to happen (to specify what the expiry keys is).
Cons:
Sarahs ApproachAnother approach (@sarahboyce suggestion) is to only make a check for the session backend ( Pros:
Cons:
|
|
Trying to make a new proposal to be closer to what Florian suggested: --- a/django/contrib/sessions/backends/signed_cookies.py
+++ b/django/contrib/sessions/backends/signed_cookies.py
@@ -1,3 +1,6 @@
+import datetime
+import time
+
from django.contrib.sessions.backends.base import SessionBase
from django.core import signing
@@ -10,13 +13,15 @@ class SessionStore(SessionBase):
if signature fails.
"""
try:
- return signing.loads(
+ payload_value, timestamp = signing.loads(
self.session_key,
serializer=self.serializer,
max_age=self.get_session_cookie_age(),
salt="django.contrib.sessions.backends.signed_cookies",
- expiration_key="_session_expiry",
+ 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.
diff --git a/django/core/signing.py b/django/core/signing.py
index 770ea1c8b5..d58a3fd890 100644
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -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,7 +170,7 @@ def loads(
serializer=JSONSerializer,
max_age=None,
fallback_keys=None,
- expiration_key=None,
+ return_timestamp=False,
):
"""
Reverse of dumps(), raise BadSignature if signature fails.
@@ -171,7 +182,7 @@ def loads(
salt=salt,
fallback_keys=fallback_keys,
).unsign_object(
- s, serializer=serializer, max_age=max_age, expiration_key=expiration_key
+ s, serializer=serializer, max_age=max_age, return_timestamp=return_timestamp
)
@@ -268,27 +279,12 @@ class TimestampSigner(Signer):
result = super().unsign(value)
value, timestamp = result.rsplit(self.sep, 1)
self.payload_timestamp = b62_decode(timestamp)
- self._verify_max_age(max_age)
+ check_signature_expiry(self.payload_timestamp, max_age=max_age)
return value
- def unsign_object(self, signed_obj, max_age=None, expiration_key=None, **kwargs):
+ 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)
-
- # Then if expiration_key is provided, check it as an additional constraint
- if expiration_key is not None:
- expiry = value.get(expiration_key)
- if expiry is not None:
- self._verify_max_age(expiry)
-
+ if return_timestamp:
+ return value, self.payload_timestamp
return value
-
- def _verify_max_age(self, 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() - self.payload_timestamp
- if age > max_age:
- raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age))This tries to use |
@sarahboyce with the above approach
is it acceptable in Django to have 2 return paths? |
|
I think we should either have a test project, or (even better) write a test to show the issues you're afraid of occurring |
|
@abe-101 my concern was slightly different, it was different code paths rather than different return types, e.g, the case where someone down the line changes Whether |
2a45040 to
016fae7
Compare
sarahboyce
left a comment
There was a problem hiding this comment.
Note we also need a release note in 6.0 👍
tests/sessions_tests/tests.py
Outdated
| ): | ||
| pass | ||
|
|
||
| def test_session_custom_expiry_load(self): |
tests/sessions_tests/tests.py
Outdated
There was a problem hiding this comment.
@sarahboyce I removed the new test test_session_custom_expiry_load(self): that I had added.
Upon review, I realized the existing test_actual_expiry test already covers this functionality.
(as well as test_actual_expiry_async which covers the async set_expiry)
There was a problem hiding this comment.
What is the point of a method solely calling it's parent? Sounds like you can just drop test_actual_expiry[_async] from this class?
ce0c1b0 to
fcb7a1a
Compare
|
@apollo13 would you mind taking a look at this PR? 🙏 |
apollo13
left a comment
There was a problem hiding this comment.
Thank you for the ping @sarahboyce. I have given this a preliminary review. Once we don't require to store something on self and are sure it works with typing in the future this should be good to go.
django/core/signing.py
Outdated
There was a problem hiding this comment.
This looks like a code smell. Why not also adjust unsign to return a timestamp as well? What makes unsign_object special in that regard?
There was a problem hiding this comment.
You raise a good point. I suppose we should also add the option to return the timestamp on unsign.
There was a problem hiding this comment.
Sorry I noticed that we still store on self, an ugly way to get around this could be:
--- a/django/core/signing.py
+++ b/django/core/signing.py
@@ -279,15 +279,15 @@ class TimestampSigner(Signer):
"""
result = super().unsign(value)
value, timestamp = result.rsplit(self.sep, 1)
- self.payload_timestamp = b62_decode(timestamp)
- check_signature_expiry(self.payload_timestamp, max_age=max_age)
+ payload_timestamp = b62_decode(timestamp)
+ check_signature_expiry(payload_timestamp, max_age=max_age)
if return_timestamp:
- return value, self.payload_timestamp
+ return value, payload_timestamp
return value
- def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, **kwargs):
- # First verify the outer max_age boundary
+ def unsign_object(self, signed_obj, max_age=None, return_timestamp=False, serializer=JSONSerializer, **kwargs):
value = super().unsign_object(signed_obj, max_age=max_age, **kwargs)
if return_timestamp:
- return value, self.payload_timestamp
+ _, timestamp = self.unsign(signed_obj, max_age=max_age, return_timestamp=True)
+ return value, timestamp
return value
But you be able to find something nicer 👍
There was a problem hiding this comment.
I rather not unsign twice scratches head. So we really might need to keep storing stuff on self (unless there are other creative options). We should make it private at least if we do so.
django/core/signing.py
Outdated
There was a problem hiding this comment.
We do not do it currently but I think in the future Django will support type hints. Is it possible to annotate a function that changes the return values based on an input boolean? I think the answer is yes, but worth checking.
There was a problem hiding this comment.
Yes, in Python 3.10+ you can indicate multiple return types with the pipe operator:
def get_value(as_string: bool = False) -> int | str:
result = 42
return str(result) if as_string else result
For Django specifically, the Technical Board has stated here:
It is the view of the Django Technical Board that inline type annotations should not be added to Django at the current time.
There was a problem hiding this comment.
I think you can use overload and literals to be more precise
There was a problem hiding this comment.
Yes, using overload sounds like a feasible approach.
tests/sessions_tests/tests.py
Outdated
There was a problem hiding this comment.
What is the point of a method solely calling it's parent? Sounds like you can just drop test_actual_expiry[_async] from this class?
You're right about this. I checked and removing the |
9904050 to
08fdc91
Compare
08fdc91 to
126f98d
Compare
sarahboyce
left a comment
There was a problem hiding this comment.
Thank you @abe-101
I pushed minor updates to docs and tests
django/core/signing.py
Outdated
There was a problem hiding this comment.
I think you can use overload and literals to be more precise
|
Been triaging ticket-36506 and it makes me wonder if we should implement |
Trac ticket number
ticket-27775
Branch description
Checklist
mainbranch.