Merge branch 'json-sessions'
This commit is contained in:
commit
e1a576122b
9 changed files with 163 additions and 53 deletions
4
CHANGES
4
CHANGES
|
|
@ -8,6 +8,10 @@ Version 0.10
|
||||||
|
|
||||||
Release date to be decided.
|
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
|
Version 0.9
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,9 @@ implementation that Flask is using.
|
||||||
.. autoclass:: SecureCookieSessionInterface
|
.. autoclass:: SecureCookieSessionInterface
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: SecureCookieSession
|
||||||
|
:members:
|
||||||
|
|
||||||
.. autoclass:: NullSession
|
.. autoclass:: NullSession
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,20 @@ installation, make sure to pass it the ``-U`` parameter::
|
||||||
|
|
||||||
$ easy_install -U Flask
|
$ 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
|
Version 0.9
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from jinja2 import Markup, escape
|
||||||
|
|
||||||
from .app import Flask, Request, Response
|
from .app import Flask, Request, Response
|
||||||
from .config import Config
|
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, \
|
send_file, send_from_directory, get_flashed_messages, \
|
||||||
get_template_attribute, make_response, safe_join, \
|
get_template_attribute, make_response, safe_join, \
|
||||||
stream_with_context
|
stream_with_context
|
||||||
|
|
@ -37,8 +37,8 @@ from .signals import signals_available, template_rendered, request_started, \
|
||||||
request_finished, got_request_exception, request_tearing_down
|
request_finished, got_request_exception, request_tearing_down
|
||||||
|
|
||||||
# only import json if it's available
|
# 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
|
# backwards compat, goes away in 1.0
|
||||||
from .sessions import SecureCookieSession as Session
|
from .sessions import SecureCookieSession as Session
|
||||||
|
json_available = True
|
||||||
|
|
|
||||||
|
|
@ -23,21 +23,9 @@ from werkzeug.routing import BuildError
|
||||||
from werkzeug.urls import url_quote
|
from werkzeug.urls import url_quote
|
||||||
from functools import update_wrapper
|
from functools import update_wrapper
|
||||||
|
|
||||||
# try to load the best simplejson implementation available. If JSON
|
# Use the same json implementation as itsdangerous on which we
|
||||||
# is not installed, we add a failing class.
|
# depend anyways.
|
||||||
json_available = True
|
from itsdangerous import simplejson as json
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
|
|
@ -55,19 +43,10 @@ from .globals import session, _request_ctx_stack, _app_ctx_stack, \
|
||||||
current_app, request
|
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
|
# figure out if simplejson escapes slashes. This behavior was changed
|
||||||
# from one version to another without reason.
|
# 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):
|
def _tojson_filter(*args, **kwargs):
|
||||||
if __debug__:
|
|
||||||
_assert_have_json()
|
|
||||||
return json.dumps(*args, **kwargs).replace('/', '\\/')
|
return json.dumps(*args, **kwargs).replace('/', '\\/')
|
||||||
else:
|
else:
|
||||||
_tojson_filter = json.dumps
|
_tojson_filter = json.dumps
|
||||||
|
|
@ -192,8 +171,6 @@ def jsonify(*args, **kwargs):
|
||||||
|
|
||||||
.. versionadded:: 0.2
|
.. versionadded:: 0.2
|
||||||
"""
|
"""
|
||||||
if __debug__:
|
|
||||||
_assert_have_json()
|
|
||||||
return current_app.response_class(json.dumps(dict(*args, **kwargs),
|
return current_app.response_class(json.dumps(dict(*args, **kwargs),
|
||||||
indent=None if request.is_xhr else 2), mimetype='application/json')
|
indent=None if request.is_xhr else 2), mimetype='application/json')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,14 @@
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from datetime import datetime
|
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):
|
class SessionMixin(object):
|
||||||
|
|
@ -41,11 +47,53 @@ class SessionMixin(object):
|
||||||
modified = True
|
modified = True
|
||||||
|
|
||||||
|
|
||||||
class SecureCookieSession(SecureCookie, SessionMixin):
|
class TaggedJSONSerializer(object):
|
||||||
"""Expands the session with support for switching between permanent
|
"""A customized JSON serializer that supports a few extra types that
|
||||||
and non-permanent sessions.
|
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 NullSession(SecureCookieSession):
|
||||||
"""Class used to generate nicer error messages if sessions are not
|
"""Class used to generate nicer error messages if sessions are not
|
||||||
|
|
@ -98,6 +146,13 @@ class SessionInterface(object):
|
||||||
#: this type.
|
#: this type.
|
||||||
null_session_class = NullSession
|
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):
|
def make_null_session(self, app):
|
||||||
"""Creates a null session which acts as a replacement object if the
|
"""Creates a null session which acts as a replacement object if the
|
||||||
real session support could not be loaded due to a configuration
|
real session support could not be loaded due to a configuration
|
||||||
|
|
@ -178,28 +233,60 @@ class SessionInterface(object):
|
||||||
|
|
||||||
|
|
||||||
class SecureCookieSessionInterface(SessionInterface):
|
class SecureCookieSessionInterface(SessionInterface):
|
||||||
"""The cookie session interface that uses the Werkzeug securecookie
|
"""The default session interface that stores sessions in signed cookies
|
||||||
as client side session backend.
|
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
|
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):
|
def open_session(self, app, request):
|
||||||
key = app.secret_key
|
s = self.get_signing_serializer(app)
|
||||||
if key is not None:
|
if s is None:
|
||||||
return self.session_class.load_cookie(request,
|
return None
|
||||||
app.session_cookie_name,
|
val = request.cookies.get(app.session_cookie_name)
|
||||||
secret_key=key)
|
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):
|
def save_session(self, app, session, response):
|
||||||
expires = self.get_expiration_time(app, session)
|
|
||||||
domain = self.get_cookie_domain(app)
|
domain = self.get_cookie_domain(app)
|
||||||
path = self.get_cookie_path(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)
|
httponly = self.get_cookie_httponly(app)
|
||||||
secure = self.get_cookie_secure(app)
|
secure = self.get_cookie_secure(app)
|
||||||
if session.modified and not session:
|
expires = self.get_expiration_time(app, session)
|
||||||
response.delete_cookie(app.session_cookie_name, path=path,
|
val = self.get_signing_serializer(app).dumps(dict(session))
|
||||||
domain=domain)
|
response.set_cookie(app.session_cookie_name, val,
|
||||||
else:
|
expires=expires, httponly=httponly,
|
||||||
session.save_cookie(response, app.session_cookie_name, path=path,
|
domain=domain, path=path, secure=secure)
|
||||||
expires=expires, httponly=httponly,
|
|
||||||
secure=secure, domain=domain)
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from __future__ import with_statement
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import flask
|
import flask
|
||||||
|
import pickle
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
@ -297,6 +298,31 @@ class BasicFunctionalityTestCase(FlaskTestCase):
|
||||||
self.assert_equal(c.get('/').data, 'None')
|
self.assert_equal(c.get('/').data, 'None')
|
||||||
self.assert_equal(c.get('/').data, '42')
|
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):
|
def test_flashes(self):
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
app.secret_key = 'testkey'
|
app.secret_key = 'testkey'
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from werkzeug.utils import cached_property
|
||||||
|
|
||||||
from .exceptions import JSONBadRequest
|
from .exceptions import JSONBadRequest
|
||||||
from .debughelpers import attach_enctype_error_multidict
|
from .debughelpers import attach_enctype_error_multidict
|
||||||
from .helpers import json, _assert_have_json
|
from .helpers import json
|
||||||
from .globals import _request_ctx_stack
|
from .globals import _request_ctx_stack
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,8 +95,6 @@ class Request(RequestBase):
|
||||||
|
|
||||||
This requires Python 2.6 or an installed version of simplejson.
|
This requires Python 2.6 or an installed version of simplejson.
|
||||||
"""
|
"""
|
||||||
if __debug__:
|
|
||||||
_assert_have_json()
|
|
||||||
if self.mimetype == 'application/json':
|
if self.mimetype == 'application/json':
|
||||||
request_charset = self.mimetype_params.get('charset')
|
request_charset = self.mimetype_params.get('charset')
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
3
setup.py
3
setup.py
|
|
@ -91,7 +91,8 @@ setup(
|
||||||
platforms='any',
|
platforms='any',
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'Werkzeug>=0.7',
|
'Werkzeug>=0.7',
|
||||||
'Jinja2>=2.4'
|
'Jinja2>=2.4',
|
||||||
|
'itsdangerous>=0.17'
|
||||||
],
|
],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 4 - Beta',
|
'Development Status :: 4 - Beta',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue