Fixed #20922 -- Allowed customizing the serializer used by contrib.sessions
Added settings.SESSION_SERIALIZER which is the import path of a serializer to use for sessions. Thanks apollo13, carljm, shaib, akaariai, charettes, and dstufft for reviews.
This commit is contained in:
parent
9d1987d767
commit
b0ce6fe656
|
@ -475,6 +475,7 @@ SESSION_SAVE_EVERY_REQUEST = False # Whether to save the se
|
|||
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Whether a user's session cookie expires when the Web browser is closed.
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db' # The module to store session data
|
||||
SESSION_FILE_PATH = None # Directory to store session files if using the file session module. If None, the backend will use a sensible default.
|
||||
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' # class to serialize session data
|
||||
|
||||
#########
|
||||
# CACHE #
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import json
|
||||
|
||||
from django.contrib.messages.storage.base import BaseStorage
|
||||
from django.contrib.messages.storage.cookie import MessageEncoder, MessageDecoder
|
||||
from django.utils import six
|
||||
|
||||
|
||||
class SessionStorage(BaseStorage):
|
||||
|
@ -20,14 +24,23 @@ class SessionStorage(BaseStorage):
|
|||
always stores everything it is given, so return True for the
|
||||
all_retrieved flag.
|
||||
"""
|
||||
return self.request.session.get(self.session_key), True
|
||||
return self.deserialize_messages(self.request.session.get(self.session_key)), True
|
||||
|
||||
def _store(self, messages, response, *args, **kwargs):
|
||||
"""
|
||||
Stores a list of messages to the request's session.
|
||||
"""
|
||||
if messages:
|
||||
self.request.session[self.session_key] = messages
|
||||
self.request.session[self.session_key] = self.serialize_messages(messages)
|
||||
else:
|
||||
self.request.session.pop(self.session_key, None)
|
||||
return []
|
||||
|
||||
def serialize_messages(self, messages):
|
||||
encoder = MessageEncoder(separators=(',', ':'))
|
||||
return encoder.encode(messages)
|
||||
|
||||
def deserialize_messages(self, data):
|
||||
if data and isinstance(data, six.string_types):
|
||||
return json.loads(data, cls=MessageDecoder)
|
||||
return data
|
||||
|
|
|
@ -61,6 +61,7 @@ class BaseTests(object):
|
|||
MESSAGE_TAGS = '',
|
||||
MESSAGE_STORAGE = '%s.%s' % (self.storage_class.__module__,
|
||||
self.storage_class.__name__),
|
||||
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer',
|
||||
)
|
||||
self.settings_override.enable()
|
||||
|
||||
|
|
|
@ -11,13 +11,13 @@ def set_session_data(storage, messages):
|
|||
Sets the messages into the backend request's session and remove the
|
||||
backend's loaded data cache.
|
||||
"""
|
||||
storage.request.session[storage.session_key] = messages
|
||||
storage.request.session[storage.session_key] = storage.serialize_messages(messages)
|
||||
if hasattr(storage, '_loaded_data'):
|
||||
del storage._loaded_data
|
||||
|
||||
|
||||
def stored_session_messages_count(storage):
|
||||
data = storage.request.session.get(storage.session_key, [])
|
||||
data = storage.deserialize_messages(storage.request.session.get(storage.session_key, []))
|
||||
return len(data)
|
||||
|
||||
|
||||
|
|
|
@ -3,11 +3,6 @@ from __future__ import unicode_literals
|
|||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
try:
|
||||
from django.utils.six.moves import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
import string
|
||||
|
||||
from django.conf import settings
|
||||
|
@ -17,6 +12,7 @@ from django.utils.crypto import get_random_string
|
|||
from django.utils.crypto import salted_hmac
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import force_bytes, force_text
|
||||
from django.utils.module_loading import import_by_path
|
||||
|
||||
from django.contrib.sessions.exceptions import SuspiciousSession
|
||||
|
||||
|
@ -42,6 +38,7 @@ class SessionBase(object):
|
|||
self._session_key = session_key
|
||||
self.accessed = False
|
||||
self.modified = False
|
||||
self.serializer = import_by_path(settings.SESSION_SERIALIZER)
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self._session
|
||||
|
@ -86,21 +83,21 @@ class SessionBase(object):
|
|||
return salted_hmac(key_salt, value).hexdigest()
|
||||
|
||||
def encode(self, session_dict):
|
||||
"Returns the given session dictionary pickled and encoded as a string."
|
||||
pickled = pickle.dumps(session_dict, pickle.HIGHEST_PROTOCOL)
|
||||
hash = self._hash(pickled)
|
||||
return base64.b64encode(hash.encode() + b":" + pickled).decode('ascii')
|
||||
"Returns the given session dictionary serialized and encoded as a string."
|
||||
serialized = self.serializer().dumps(session_dict)
|
||||
hash = self._hash(serialized)
|
||||
return base64.b64encode(hash.encode() + b":" + serialized).decode('ascii')
|
||||
|
||||
def decode(self, session_data):
|
||||
encoded_data = base64.b64decode(force_bytes(session_data))
|
||||
try:
|
||||
# could produce ValueError if there is no ':'
|
||||
hash, pickled = encoded_data.split(b':', 1)
|
||||
expected_hash = self._hash(pickled)
|
||||
hash, serialized = encoded_data.split(b':', 1)
|
||||
expected_hash = self._hash(serialized)
|
||||
if not constant_time_compare(hash.decode(), expected_hash):
|
||||
raise SuspiciousSession("Session data corrupted")
|
||||
else:
|
||||
return pickle.loads(pickled)
|
||||
return self.serializer().loads(serialized)
|
||||
except Exception as e:
|
||||
# ValueError, SuspiciousOperation, unpickling exceptions. If any of
|
||||
# these happen, just return an empty dictionary (an empty session).
|
||||
|
|
|
@ -1,26 +1,9 @@
|
|||
try:
|
||||
from django.utils.six.moves import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
|
||||
class PickleSerializer(object):
|
||||
"""
|
||||
Simple wrapper around pickle to be used in signing.dumps and
|
||||
signing.loads.
|
||||
"""
|
||||
def dumps(self, obj):
|
||||
return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
def loads(self, data):
|
||||
return pickle.loads(data)
|
||||
|
||||
|
||||
class SessionStore(SessionBase):
|
||||
|
||||
def load(self):
|
||||
|
@ -31,7 +14,7 @@ class SessionStore(SessionBase):
|
|||
"""
|
||||
try:
|
||||
return signing.loads(self.session_key,
|
||||
serializer=PickleSerializer,
|
||||
serializer=self.serializer,
|
||||
# This doesn't handle non-default expiry dates, see #19201
|
||||
max_age=settings.SESSION_COOKIE_AGE,
|
||||
salt='django.contrib.sessions.backends.signed_cookies')
|
||||
|
@ -91,7 +74,7 @@ class SessionStore(SessionBase):
|
|||
session_cache = getattr(self, '_session_cache', {})
|
||||
return signing.dumps(session_cache, compress=True,
|
||||
salt='django.contrib.sessions.backends.signed_cookies',
|
||||
serializer=PickleSerializer)
|
||||
serializer=self.serializer)
|
||||
|
||||
@classmethod
|
||||
def clear_expired(cls):
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
class SessionManager(models.Manager):
|
||||
def encode(self, session_dict):
|
||||
"""
|
||||
Returns the given session dictionary pickled and encoded as a string.
|
||||
Returns the given session dictionary serialized and encoded as a string.
|
||||
"""
|
||||
return SessionStore().encode(session_dict)
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
from django.core.signing import JSONSerializer as BaseJSONSerializer
|
||||
try:
|
||||
from django.utils.six.moves import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
|
||||
|
||||
class PickleSerializer(object):
|
||||
"""
|
||||
Simple wrapper around pickle to be used in signing.dumps and
|
||||
signing.loads.
|
||||
"""
|
||||
def dumps(self, obj):
|
||||
return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
def loads(self, data):
|
||||
return pickle.loads(data)
|
||||
|
||||
|
||||
JSONSerializer = BaseJSONSerializer
|
|
@ -285,21 +285,25 @@ class SessionTestsMixin(object):
|
|||
|
||||
|
||||
def test_actual_expiry(self):
|
||||
# Regression test for #19200
|
||||
old_session_key = None
|
||||
new_session_key = None
|
||||
try:
|
||||
self.session['foo'] = 'bar'
|
||||
self.session.set_expiry(-timedelta(seconds=10))
|
||||
self.session.save()
|
||||
old_session_key = self.session.session_key
|
||||
# With an expiry date in the past, the session expires instantly.
|
||||
new_session = self.backend(self.session.session_key)
|
||||
new_session_key = new_session.session_key
|
||||
self.assertNotIn('foo', new_session)
|
||||
finally:
|
||||
self.session.delete(old_session_key)
|
||||
self.session.delete(new_session_key)
|
||||
# this doesn't work with JSONSerializer (serializing timedelta)
|
||||
with override_settings(SESSION_SERIALIZER='django.contrib.sessions.serializers.PickleSerializer'):
|
||||
self.session = self.backend() # reinitialize after overriding settings
|
||||
|
||||
# Regression test for #19200
|
||||
old_session_key = None
|
||||
new_session_key = None
|
||||
try:
|
||||
self.session['foo'] = 'bar'
|
||||
self.session.set_expiry(-timedelta(seconds=10))
|
||||
self.session.save()
|
||||
old_session_key = self.session.session_key
|
||||
# With an expiry date in the past, the session expires instantly.
|
||||
new_session = self.backend(self.session.session_key)
|
||||
new_session_key = new_session.session_key
|
||||
self.assertNotIn('foo', new_session)
|
||||
finally:
|
||||
self.session.delete(old_session_key)
|
||||
self.session.delete(new_session_key)
|
||||
|
||||
|
||||
class DatabaseSessionTests(SessionTestsMixin, TestCase):
|
||||
|
|
|
@ -2403,7 +2403,7 @@ SESSION_ENGINE
|
|||
|
||||
Default: ``django.contrib.sessions.backends.db``
|
||||
|
||||
Controls where Django stores session data. Valid values are:
|
||||
Controls where Django stores session data. Included engines are:
|
||||
|
||||
* ``'django.contrib.sessions.backends.db'``
|
||||
* ``'django.contrib.sessions.backends.file'``
|
||||
|
@ -2446,6 +2446,28 @@ Whether to save the session data on every request. If this is ``False``
|
|||
(default), then the session data will only be saved if it has been modified --
|
||||
that is, if any of its dictionary values have been assigned or deleted.
|
||||
|
||||
.. setting:: SESSION_SERIALIZER
|
||||
|
||||
SESSION_SERIALIZER
|
||||
------------------
|
||||
|
||||
Default: ``'django.contrib.sessions.serializers.JSONSerializer'``
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The default switched from
|
||||
:class:`~django.contrib.sessions.serializers.PickleSerializer` to
|
||||
:class:`~django.contrib.sessions.serializers.JSONSerializer` in Django 1.6.
|
||||
|
||||
Full import path of a serializer class to use for serializing session data.
|
||||
Included serializers are:
|
||||
|
||||
* ``'django.contrib.sessions.serializers.PickleSerializer'``
|
||||
* ``'django.contrib.sessions.serializers.JSONSerializer'``
|
||||
|
||||
See :ref:`session_serialization` for details, including a warning regarding
|
||||
possible remote code execution when using
|
||||
:class:`~django.contrib.sessions.serializers.PickleSerializer`.
|
||||
|
||||
Sites
|
||||
=====
|
||||
|
|
|
@ -727,6 +727,29 @@ the ``name`` argument so it doesn't conflict with the new url::
|
|||
You can remove this url pattern after your app has been deployed with Django
|
||||
1.6 for :setting:`PASSWORD_RESET_TIMEOUT_DAYS`.
|
||||
|
||||
Default session serialization switched to JSON
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Historically, :mod:`django.contrib.sessions` used :mod:`pickle` to serialize
|
||||
session data before storing it in the backend. If you're using the :ref:`signed
|
||||
cookie session backend<cookie-session-backend>` and :setting:`SECRET_KEY` is
|
||||
known by an attacker, the attacker could insert a string into his session
|
||||
which, when unpickled, executes arbitrary code on the server. The technique for
|
||||
doing so is simple and easily available on the internet. Although the cookie
|
||||
session storage signs the cookie-stored data to prevent tampering, a
|
||||
:setting:`SECRET_KEY` leak immediately escalates to a remote code execution
|
||||
vulnerability.
|
||||
|
||||
This attack can be mitigated by serializing session data using JSON rather
|
||||
than :mod:`pickle`. To facilitate this, Django 1.5.3 introduced a new setting,
|
||||
:setting:`SESSION_SERIALIZER`, to customize the session serialization format.
|
||||
For backwards compatibility, this setting defaulted to using :mod:`pickle`
|
||||
in Django 1.5.3, but we've changed the default to JSON in 1.6. If you upgrade
|
||||
and switch from pickle to JSON, sessions created before the upgrade will be
|
||||
lost. While JSON serialization does not support all Python objects like
|
||||
:mod:`pickle` does, we highly recommend using JSON-serialized sessions. See the
|
||||
:ref:`session_serialization` documentation for more details.
|
||||
|
||||
Miscellaneous
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
@ -128,8 +128,9 @@ and the :setting:`SECRET_KEY` setting.
|
|||
|
||||
.. warning::
|
||||
|
||||
**If the SECRET_KEY is not kept secret, this can lead to arbitrary remote
|
||||
code execution.**
|
||||
**If the SECRET_KEY is not kept secret and you are using the**
|
||||
:class:`~django.contrib.sessions.serializers.PickleSerializer`, **this can
|
||||
lead to arbitrary remote code execution.**
|
||||
|
||||
An attacker in possession of the :setting:`SECRET_KEY` can not only
|
||||
generate falsified session data, which your site will trust, but also
|
||||
|
@ -256,7 +257,9 @@ You can edit it multiple times.
|
|||
in 5 minutes.
|
||||
|
||||
* If ``value`` is a ``datetime`` or ``timedelta`` object, the
|
||||
session will expire at that specific date/time.
|
||||
session will expire at that specific date/time. Note that ``datetime``
|
||||
and ``timedelta`` values are only serializable if you are using the
|
||||
:class:`~django.contrib.sessions.serializers.PickleSerializer`.
|
||||
|
||||
* If ``value`` is ``0``, the user's session cookie will expire
|
||||
when the user's Web browser is closed.
|
||||
|
@ -301,6 +304,72 @@ You can edit it multiple times.
|
|||
Removes expired sessions from the session store. This class method is
|
||||
called by :djadmin:`clearsessions`.
|
||||
|
||||
.. _session_serialization:
|
||||
|
||||
Session serialization
|
||||
---------------------
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
Before version 1.6, Django defaulted to using :mod:`pickle` to serialize
|
||||
session data before storing it in the backend. If you're using the :ref:`signed
|
||||
cookie session backend<cookie-session-backend>` and :setting:`SECRET_KEY` is
|
||||
known by an attacker, the attacker could insert a string into his session
|
||||
which, when unpickled, executes arbitrary code on the server. The technique for
|
||||
doing so is simple and easily available on the internet. Although the cookie
|
||||
session storage signs the cookie-stored data to prevent tampering, a
|
||||
:setting:`SECRET_KEY` leak immediately escalates to a remote code execution
|
||||
vulnerability.
|
||||
|
||||
This attack can be mitigated by serializing session data using JSON rather
|
||||
than :mod:`pickle`. To facilitate this, Django 1.5.3 introduced a new setting,
|
||||
:setting:`SESSION_SERIALIZER`, to customize the session serialization format.
|
||||
For backwards compatibility, this setting defaults to
|
||||
using :class:`django.contrib.sessions.serializers.PickleSerializer` in
|
||||
Django 1.5.x, but, for security hardening, defaults to
|
||||
:class:`django.contrib.sessions.serializers.JSONSerializer` in Django 1.6.
|
||||
Even with the caveats described in :ref:`custom-serializers`, we highly
|
||||
recommend sticking with JSON serialization *especially if you are using the
|
||||
cookie backend*.
|
||||
|
||||
Bundled Serializers
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. class:: serializers.JSONSerializer
|
||||
|
||||
A wrapper around the JSON serializer from :mod:`django.core.signing`. Can
|
||||
only serialize basic data types. See the :ref:`custom-serializers` section
|
||||
for more details.
|
||||
|
||||
.. class:: serializers.PickleSerializer
|
||||
|
||||
Supports arbitrary Python objects, but, as described above, can lead to a
|
||||
remote code execution vulnerability if :setting:`SECRET_KEY` becomes known
|
||||
by an attacker.
|
||||
|
||||
.. _custom-serializers:
|
||||
|
||||
Write Your Own Serializer
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Note that unlike :class:`~django.contrib.sessions.serializers.PickleSerializer`,
|
||||
the :class:`~django.contrib.sessions.serializers.JSONSerializer` cannot handle
|
||||
arbitrary Python data types. As is often the case, there is a trade-off between
|
||||
convenience and security. If you wish to store more advanced data types
|
||||
including ``datetime`` and ``Decimal`` in JSON backed sessions, you will need
|
||||
to write a custom serializer (or convert such values to a JSON serializable
|
||||
object before storing them in ``request.session``). While serializing these
|
||||
values is fairly straightforward
|
||||
(``django.core.serializers.json.DateTimeAwareJSONEncoder`` may be helpful),
|
||||
writing a decoder that can reliably get back the same thing that you put in is
|
||||
more fragile. For example, you run the risk of returning a ``datetime`` that
|
||||
was actually a string that just happened to be in the same format chosen for
|
||||
``datetime``\s).
|
||||
|
||||
Your serializer class must implement two methods,
|
||||
``dumps(self, obj)`` and ``loads(self, data)``, to serialize and deserialize
|
||||
the dictionary of session data, respectively.
|
||||
|
||||
Session object guidelines
|
||||
-------------------------
|
||||
|
||||
|
@ -390,14 +459,15 @@ An API is available to manipulate session data outside of a view::
|
|||
>>> from django.contrib.sessions.backends.db import SessionStore
|
||||
>>> import datetime
|
||||
>>> s = SessionStore()
|
||||
>>> s['last_login'] = datetime.datetime(2005, 8, 20, 13, 35, 10)
|
||||
>>> # stored as seconds since epoch since datetimes are not serializable in JSON.
|
||||
>>> s['last_login'] = 1376587691
|
||||
>>> s.save()
|
||||
>>> s.session_key
|
||||
'2b1189a188b44ad18c35e113ac6ceead'
|
||||
|
||||
>>> s = SessionStore(session_key='2b1189a188b44ad18c35e113ac6ceead')
|
||||
>>> s['last_login']
|
||||
datetime.datetime(2005, 8, 20, 13, 35, 0)
|
||||
1376587691
|
||||
|
||||
In order to prevent session fixation attacks, sessions keys that don't exist
|
||||
are regenerated::
|
||||
|
@ -543,8 +613,11 @@ behavior:
|
|||
Technical details
|
||||
=================
|
||||
|
||||
* The session dictionary should accept any pickleable Python object. See
|
||||
the :mod:`pickle` module for more information.
|
||||
* The session dictionary accepts any :mod:`json` serializable value when using
|
||||
:class:`~django.contrib.sessions.serializers.JSONSerializer` or any
|
||||
pickleable Python object when using
|
||||
:class:`~django.contrib.sessions.serializers.PickleSerializer`. See the
|
||||
:mod:`pickle` module for more information.
|
||||
|
||||
* Session data is stored in a database table named ``django_session`` .
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.sessions.backends.db import SessionStore
|
|||
from django.db.models import Count
|
||||
from django.db.models.loading import cache
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from .models import (
|
||||
ResolveThis, Item, RelatedItem, Child, Leaf, Proxy, SimpleItem, Feature,
|
||||
|
@ -83,24 +84,6 @@ class DeferRegressionTest(TestCase):
|
|||
self.assertEqual(results[0].child.name, "c1")
|
||||
self.assertEqual(results[0].second_child.name, "c2")
|
||||
|
||||
# Test for #12163 - Pickling error saving session with unsaved model
|
||||
# instances.
|
||||
SESSION_KEY = '2b1189a188b44ad18c35e1baac6ceead'
|
||||
|
||||
item = Item()
|
||||
item._deferred = False
|
||||
s = SessionStore(SESSION_KEY)
|
||||
s.clear()
|
||||
s["item"] = item
|
||||
s.save()
|
||||
|
||||
s = SessionStore(SESSION_KEY)
|
||||
s.modified = True
|
||||
s.save()
|
||||
|
||||
i2 = s["item"]
|
||||
self.assertFalse(i2._deferred)
|
||||
|
||||
# Regression for #16409 - make sure defer() and only() work with annotate()
|
||||
self.assertIsInstance(
|
||||
list(SimpleItem.objects.annotate(Count('feature')).defer('name')),
|
||||
|
@ -147,6 +130,27 @@ class DeferRegressionTest(TestCase):
|
|||
cache.get_app("defer_regress"), include_deferred=True))
|
||||
)
|
||||
|
||||
@override_settings(SESSION_SERIALIZER='django.contrib.sessions.serializers.PickleSerializer')
|
||||
def test_ticket_12163(self):
|
||||
# Test for #12163 - Pickling error saving session with unsaved model
|
||||
# instances.
|
||||
SESSION_KEY = '2b1189a188b44ad18c35e1baac6ceead'
|
||||
|
||||
item = Item()
|
||||
item._deferred = False
|
||||
s = SessionStore(SESSION_KEY)
|
||||
s.clear()
|
||||
s["item"] = item
|
||||
s.save()
|
||||
|
||||
s = SessionStore(SESSION_KEY)
|
||||
s.modified = True
|
||||
s.save()
|
||||
|
||||
i2 = s["item"]
|
||||
self.assertFalse(i2._deferred)
|
||||
|
||||
def test_ticket_16409(self):
|
||||
# Regression for #16409 - make sure defer() and only() work with annotate()
|
||||
self.assertIsInstance(
|
||||
list(SimpleItem.objects.annotate(Count('feature')).defer('name')),
|
||||
|
|
Loading…
Reference in New Issue