-
Notifications
You must be signed in to change notification settings - Fork 25
INTPYTHON-676 Add optional signing of cache data #336
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
40dbb00
c35b2e8
f64ee67
36efcee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,27 +1,33 @@ | ||||||||||||||||
import pickle | ||||||||||||||||
from datetime import datetime, timezone | ||||||||||||||||
from base64 import b64encode, b64decode | ||||||||||||||||
|
||||||||||||||||
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache | ||||||||||||||||
from django.core.cache.backends.db import Options | ||||||||||||||||
from django.core.signing import Signer, BadSignature | ||||||||||||||||
from django.db import connections, router | ||||||||||||||||
from django.utils.functional import cached_property | ||||||||||||||||
from pymongo import ASCENDING, DESCENDING, IndexModel, ReturnDocument | ||||||||||||||||
from pymongo.errors import DuplicateKeyError, OperationFailure | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
class MongoSerializer: | ||||||||||||||||
def __init__(self, protocol=None): | ||||||||||||||||
def __init__(self, protocol=None, signer=True, salt=None): | ||||||||||||||||
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol | ||||||||||||||||
self.signer = Signer(salt=salt) if signer else None | ||||||||||||||||
|
||||||||||||||||
def dumps(self, obj): | ||||||||||||||||
# For better incr() and decr() atomicity, don't pickle integers. | ||||||||||||||||
# Using type() rather than isinstance() matches only integers and not | ||||||||||||||||
# subclasses like bool. | ||||||||||||||||
if type(obj) is int: # noqa: E721 | ||||||||||||||||
return obj | ||||||||||||||||
return pickle.dumps(obj, self.protocol) | ||||||||||||||||
return obj if self.signer is None else self.signer.sign(b64encode(obj)) | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this doesn't need to be b64encoded. On line 29/30, you can first unsign, and then check if the type after unsigning comes back as an int. If it does, then you can just return the int. |
||||||||||||||||
pickled_data = pickle.dumps(obj, protocol=self.protocol) # noqa: S301 | ||||||||||||||||
return self.signer.sign(b64encode(pickled_data).decode()) if self.signer else pickled_data | ||||||||||||||||
|
||||||||||||||||
def loads(self, data): | ||||||||||||||||
if self.signer is not None: | ||||||||||||||||
data = b64decode(self.signer.unsign(data)) | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I considered this when I was originally writing the function, but |
||||||||||||||||
try: | ||||||||||||||||
return int(data) | ||||||||||||||||
except (ValueError, TypeError): | ||||||||||||||||
|
@@ -39,6 +45,8 @@ class CacheEntry: | |||||||||||||||
_meta = Options(collection_name) | ||||||||||||||||
|
||||||||||||||||
self.cache_model_class = CacheEntry | ||||||||||||||||
self._sign_cache = params.get("ENABLE_SIGNING", True) | ||||||||||||||||
self._salt = params.get("SALT", None) | ||||||||||||||||
|
||||||||||||||||
def create_indexes(self): | ||||||||||||||||
expires_index = IndexModel("expires_at", expireAfterSeconds=0) | ||||||||||||||||
|
@@ -47,7 +55,7 @@ def create_indexes(self): | |||||||||||||||
|
||||||||||||||||
@cached_property | ||||||||||||||||
def serializer(self): | ||||||||||||||||
return MongoSerializer(self.pickle_protocol) | ||||||||||||||||
return MongoSerializer(self.pickle_protocol, self._sign_cache, self._salt) | ||||||||||||||||
|
||||||||||||||||
@property | ||||||||||||||||
def collection_for_read(self): | ||||||||||||||||
|
@@ -84,7 +92,13 @@ def get_many(self, keys, version=None): | |||||||||||||||
with self.collection_for_read.find( | ||||||||||||||||
{"key": {"$in": tuple(keys_map)}, **self._filter_expired(expired=False)} | ||||||||||||||||
) as cursor: | ||||||||||||||||
return {keys_map[row["key"]]: self.serializer.loads(row["value"]) for row in cursor} | ||||||||||||||||
results = {} | ||||||||||||||||
for row in cursor: | ||||||||||||||||
try: | ||||||||||||||||
results[keys_map[row["key"]]] = self.serializer.loads(row["value"]) | ||||||||||||||||
except (BadSignature, TypeError): | ||||||||||||||||
self.delete(row["key"]) | ||||||||||||||||
Comment on lines
+99
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm having trouble understanding why we're catching There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the data in the cache collection is tampered with or somehow the HMAC secret key or salt is changed, a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A better behavior than silently deleting bad data (which could happen in what circumstances besides an attacker putting malicious data in the cache?) could be to raise (or at least log) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I explained in a above comment the cases I was able to create where these two exceptions were thrown. If we do not delete the entry, won't we just create a DOS of the affected page until the cache entry is culled? I do agree that this probably shouldn't be silent, but throwing an error here will stop the request from being handled, generate a 500, and require the request to be resent. I think that is probably ok if we delete the offending cache entry so only one request would be affected by the issue. What do you think? |
||||||||||||||||
return results | ||||||||||||||||
|
||||||||||||||||
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): | ||||||||||||||||
key = self.make_and_validate_key(key, version=version) | ||||||||||||||||
|
Uh oh!
There was an error while loading. Please reload this page.