-
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
base: main
Are you sure you want to change the base?
Changes from all 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,30 +1,36 @@ | ||
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) | ||
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): | ||
try: | ||
return int(data) | ||
except (ValueError, TypeError): | ||
if self.signer is not None: | ||
data = b64decode(self.signer.unsign(data)) | ||
return pickle.loads(data) # noqa: S301 | ||
|
||
|
||
|
@@ -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"]) | ||
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) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having trouble understanding why we're catching
BadSignature
andTypeError
issues. What would trigger this and why would it be okay to delete a row in these instances?There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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
BadSignature
error is thrown. I added error handling here to delete the row otherwise the exception will DOS the page until the cache entry gets culled.TypeError
can be thrown if you switch fromENABLE_SIGNING=True
toENABLE_SIGNING=False
without clearing all cache entries. It's highly unlikely that will ever happen in prod, but I was able to get that error while in changing settings in debug mode.