Skip to content

Commit

Permalink
Merge branch 'json-sessions'
Browse files Browse the repository at this point in the history
  • Loading branch information
mitsuhiko committed Oct 7, 2012
2 parents 22af78a + fe85970 commit e1a5761
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 53 deletions.
4 changes: 4 additions & 0 deletions CHANGES
Expand Up @@ -8,6 +8,10 @@ Version 0.10

Release date to be decided.

- Changed default cookie serialization format from pickle to JSON to
limit the impact an attacker can do if the secret key leaks. See
:ref:`upgrading-to-010` for more information.

Version 0.9
-----------

Expand Down
3 changes: 3 additions & 0 deletions docs/api.rst
Expand Up @@ -215,6 +215,9 @@ implementation that Flask is using.
.. autoclass:: SecureCookieSessionInterface
:members:

.. autoclass:: SecureCookieSession
:members:

.. autoclass:: NullSession
:members:

Expand Down
14 changes: 14 additions & 0 deletions docs/upgrading.rst
Expand Up @@ -19,6 +19,20 @@ installation, make sure to pass it the ``-U`` parameter::

$ easy_install -U Flask

.. _upgrading-to-010:

Version 0.10
------------

The biggest change going from 0.9 to 0.10 is that the cookie serialization
format changed from pickle to a specialized JSON format. This change has
been done in order to avoid the damage an attacker can do if the secret
key is leaked. When you upgrade you will notice two major changes: all
sessions that were issued before the upgrade are invalidated and you can
only store a limited amount of types in the session.

TODO: add external module for session upgrading

Version 0.9
-----------

Expand Down
6 changes: 3 additions & 3 deletions flask/__init__.py
Expand Up @@ -20,7 +20,7 @@

from .app import Flask, Request, Response
from .config import Config
from .helpers import url_for, jsonify, json_available, flash, \
from .helpers import url_for, jsonify, flash, \
send_file, send_from_directory, get_flashed_messages, \
get_template_attribute, make_response, safe_join, \
stream_with_context
Expand All @@ -37,8 +37,8 @@
request_finished, got_request_exception, request_tearing_down

# only import json if it's available
if json_available:
from .helpers import json
from .helpers import json

# backwards compat, goes away in 1.0
from .sessions import SecureCookieSession as Session
json_available = True
31 changes: 4 additions & 27 deletions flask/helpers.py
Expand Up @@ -23,21 +23,9 @@
from werkzeug.urls import url_quote
from functools import update_wrapper

# try to load the best simplejson implementation available. If JSON
# is not installed, we add a failing class.
json_available = True
json = None
try:
import simplejson as json
except ImportError:
try:
import json
except ImportError:
try:
# Google Appengine offers simplejson via django
from django.utils import simplejson as json
except ImportError:
json_available = False
# Use the same json implementation as itsdangerous on which we
# depend anyways.
from itsdangerous import simplejson as json


from werkzeug.datastructures import Headers
Expand All @@ -55,19 +43,10 @@
current_app, request


def _assert_have_json():
"""Helper function that fails if JSON is unavailable."""
if not json_available:
raise RuntimeError('simplejson not installed')


# figure out if simplejson escapes slashes. This behavior was changed
# from one version to another without reason.
if not json_available or '\\/' not in json.dumps('/'):

if '\\/' not in json.dumps('/'):
def _tojson_filter(*args, **kwargs):
if __debug__:
_assert_have_json()
return json.dumps(*args, **kwargs).replace('/', '\\/')
else:
_tojson_filter = json.dumps
Expand Down Expand Up @@ -192,8 +171,6 @@ def get_current_user():
.. versionadded:: 0.2
"""
if __debug__:
_assert_have_json()
return current_app.response_class(json.dumps(dict(*args, **kwargs),
indent=None if request.is_xhr else 2), mimetype='application/json')

Expand Down
125 changes: 106 additions & 19 deletions flask/sessions.py
Expand Up @@ -10,8 +10,14 @@
:license: BSD, see LICENSE for more details.
"""

import hashlib
from datetime import datetime
from werkzeug.contrib.securecookie import SecureCookie
from werkzeug.http import http_date, parse_date
from werkzeug.datastructures import CallbackDict
from .helpers import json
from . import Markup

from itsdangerous import URLSafeTimedSerializer, BadSignature


class SessionMixin(object):
Expand Down Expand Up @@ -41,11 +47,53 @@ def _set_permanent(self, value):
modified = True


class SecureCookieSession(SecureCookie, SessionMixin):
"""Expands the session with support for switching between permanent
and non-permanent sessions.
class TaggedJSONSerializer(object):
"""A customized JSON serializer that supports a few extra types that
we take for granted when serializing (tuples, markup objects, datetime).
"""

def dumps(self, value):
def _tag(value):
if isinstance(value, tuple):
return {' t': [_tag(x) for x in value]}
elif callable(getattr(value, '__html__', None)):
return {' m': unicode(value.__html__())}
elif isinstance(value, list):
return [_tag(x) for x in value]
elif isinstance(value, datetime):
return {' d': http_date(value)}
elif isinstance(value, dict):
return dict((k, _tag(v)) for k, v in value.iteritems())
return value
return json.dumps(_tag(value), separators=(',', ':'))

def loads(self, value):
def object_hook(obj):
if len(obj) != 1:
return obj
the_key, the_value = obj.iteritems().next()
if the_key == ' t':
return tuple(the_value)
elif the_key == ' m':
return Markup(the_value)
elif the_key == ' d':
return parse_date(the_value)
return obj
return json.loads(value, object_hook=object_hook)


session_json_serializer = TaggedJSONSerializer()


class SecureCookieSession(CallbackDict, SessionMixin):
"""Baseclass for sessions based on signed cookies."""

def __init__(self, initial=None):
def on_update(self):
self.modified = True
CallbackDict.__init__(self, initial, on_update)
self.modified = False


class NullSession(SecureCookieSession):
"""Class used to generate nicer error messages if sessions are not
Expand Down Expand Up @@ -98,6 +146,13 @@ class Session(dict, SessionMixin):
#: this type.
null_session_class = NullSession

#: A flag that indicates if the session interface is pickle based.
#: This can be used by flask extensions to make a decision in regards
#: to how to deal with the session object.
#:
#: .. versionadded:: 0.10
pickle_based = False

def make_null_session(self, app):
"""Creates a null session which acts as a replacement object if the
real session support could not be loaded due to a configuration
Expand Down Expand Up @@ -178,28 +233,60 @@ def save_session(self, app, session, response):


class SecureCookieSessionInterface(SessionInterface):
"""The cookie session interface that uses the Werkzeug securecookie
as client side session backend.
"""The default session interface that stores sessions in signed cookies
through the :mod:`itsdangerous` module.
"""
#: the salt that should be applied on top of the secret key for the
#: signing of cookie based sessions.
salt = 'cookie-session'
#: the hash function to use for the signature. The default is sha1
digest_method = staticmethod(hashlib.sha1)
#: the name of the itsdangerous supported key derivation. The default
#: is hmac.
key_derivation = 'hmac'
#: A python serializer for the payload. The default is a compact
#: JSON derived serializer with support for some extra Python types
#: such as datetime objects or tuples.
serializer = session_json_serializer
session_class = SecureCookieSession

def get_signing_serializer(self, app):
if not app.secret_key:
return None
signer_kwargs = dict(
key_derivation=self.key_derivation,
digest_method=self.digest_method
)
return URLSafeTimedSerializer(app.secret_key, salt=self.salt,
serializer=self.serializer,
signer_kwargs=signer_kwargs)

def open_session(self, app, request):
key = app.secret_key
if key is not None:
return self.session_class.load_cookie(request,
app.session_cookie_name,
secret_key=key)
s = self.get_signing_serializer(app)
if s is None:
return None
val = request.cookies.get(app.session_cookie_name)
if not val:
return self.session_class()
max_age = app.permanent_session_lifetime.total_seconds()
try:
data = s.loads(val, max_age=max_age)
return self.session_class(data)
except BadSignature:
return self.session_class()

def save_session(self, app, session, response):
expires = self.get_expiration_time(app, session)
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if not session:
if session.modified:
response.delete_cookie(app.session_cookie_name,
domain=domain, path=path)
return
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
if session.modified and not session:
response.delete_cookie(app.session_cookie_name, path=path,
domain=domain)
else:
session.save_cookie(response, app.session_cookie_name, path=path,
expires=expires, httponly=httponly,
secure=secure, domain=domain)
expires = self.get_expiration_time(app, session)
val = self.get_signing_serializer(app).dumps(dict(session))
response.set_cookie(app.session_cookie_name, val,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
26 changes: 26 additions & 0 deletions flask/testsuite/basic.py
Expand Up @@ -13,6 +13,7 @@

import re
import flask
import pickle
import unittest
from datetime import datetime
from threading import Thread
Expand Down Expand Up @@ -297,6 +298,31 @@ def dump_session_contents():
self.assert_equal(c.get('/').data, 'None')
self.assert_equal(c.get('/').data, '42')

def test_session_special_types(self):
app = flask.Flask(__name__)
app.secret_key = 'development-key'
app.testing = True
now = datetime.utcnow().replace(microsecond=0)

@app.after_request
def modify_session(response):
flask.session['m'] = flask.Markup('Hello!')
flask.session['dt'] = now
flask.session['t'] = (1, 2, 3)
return response

@app.route('/')
def dump_session_contents():
return pickle.dumps(dict(flask.session))

c = app.test_client()
c.get('/')
rv = pickle.loads(c.get('/').data)
self.assert_equal(rv['m'], flask.Markup('Hello!'))
self.assert_equal(type(rv['m']), flask.Markup)
self.assert_equal(rv['dt'], now)
self.assert_equal(rv['t'], (1, 2, 3))

def test_flashes(self):
app = flask.Flask(__name__)
app.secret_key = 'testkey'
Expand Down
4 changes: 1 addition & 3 deletions flask/wrappers.py
Expand Up @@ -14,7 +14,7 @@

from .exceptions import JSONBadRequest
from .debughelpers import attach_enctype_error_multidict
from .helpers import json, _assert_have_json
from .helpers import json
from .globals import _request_ctx_stack


Expand Down Expand Up @@ -95,8 +95,6 @@ def json(self):
This requires Python 2.6 or an installed version of simplejson.
"""
if __debug__:
_assert_have_json()
if self.mimetype == 'application/json':
request_charset = self.mimetype_params.get('charset')
try:
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Expand Up @@ -91,7 +91,8 @@ def run(self):
platforms='any',
install_requires=[
'Werkzeug>=0.7',
'Jinja2>=2.4'
'Jinja2>=2.4',
'itsdangerous>=0.17'
],
classifiers=[
'Development Status :: 4 - Beta',
Expand Down

0 comments on commit e1a5761

Please sign in to comment.