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:
Tim Graham 2013-08-21 20:12:19 -04:00
parent 9d1987d767
commit b0ce6fe656
13 changed files with 218 additions and 77 deletions

View File

@ -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 #

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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).

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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
=====

View File

@ -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
~~~~~~~~~~~~~

View File

@ -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`` .

View File

@ -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')),