Skip to content
This repository
Browse code

Merge branch 'json-sessions'

  • Loading branch information...
commit e1a576122be4675c3c648c0a4eccc075b3e15a29 2 parents 22af78a + fe85970
Armin Ronacher authored
4  CHANGES
@@ -8,6 +8,10 @@ Version 0.10
8 8
 
9 9
 Release date to be decided.
10 10
 
  11
+- Changed default cookie serialization format from pickle to JSON to
  12
+  limit the impact an attacker can do if the secret key leaks.  See
  13
+  :ref:`upgrading-to-010` for more information.
  14
+
11 15
 Version 0.9
12 16
 -----------
13 17
 
3  docs/api.rst
Source Rendered
@@ -215,6 +215,9 @@ implementation that Flask is using.
215 215
 .. autoclass:: SecureCookieSessionInterface
216 216
    :members:
217 217
 
  218
+.. autoclass:: SecureCookieSession
  219
+   :members:
  220
+
218 221
 .. autoclass:: NullSession
219 222
    :members:
220 223
 
14  docs/upgrading.rst
Source Rendered
@@ -19,6 +19,20 @@ installation, make sure to pass it the ``-U`` parameter::
19 19
 
20 20
     $ easy_install -U Flask
21 21
 
  22
+.. _upgrading-to-010:
  23
+
  24
+Version 0.10
  25
+------------
  26
+
  27
+The biggest change going from 0.9 to 0.10 is that the cookie serialization
  28
+format changed from pickle to a specialized JSON format.  This change has
  29
+been done in order to avoid the damage an attacker can do if the secret
  30
+key is leaked.  When you upgrade you will notice two major changes: all
  31
+sessions that were issued before the upgrade are invalidated and you can
  32
+only store a limited amount of types in the session.
  33
+
  34
+TODO: add external module for session upgrading
  35
+
22 36
 Version 0.9
23 37
 -----------
24 38
 
6  flask/__init__.py
@@ -20,7 +20,7 @@
20 20
 
21 21
 from .app import Flask, Request, Response
22 22
 from .config import Config
23  
-from .helpers import url_for, jsonify, json_available, flash, \
  23
+from .helpers import url_for, jsonify, flash, \
24 24
     send_file, send_from_directory, get_flashed_messages, \
25 25
     get_template_attribute, make_response, safe_join, \
26 26
     stream_with_context
@@ -37,8 +37,8 @@
37 37
      request_finished, got_request_exception, request_tearing_down
38 38
 
39 39
 # only import json if it's available
40  
-if json_available:
41  
-    from .helpers import json
  40
+from .helpers import json
42 41
 
43 42
 # backwards compat, goes away in 1.0
44 43
 from .sessions import SecureCookieSession as Session
  44
+json_available = True
31  flask/helpers.py
@@ -23,21 +23,9 @@
23 23
 from werkzeug.urls import url_quote
24 24
 from functools import update_wrapper
25 25
 
26  
-# try to load the best simplejson implementation available.  If JSON
27  
-# is not installed, we add a failing class.
28  
-json_available = True
29  
-json = None
30  
-try:
31  
-    import simplejson as json
32  
-except ImportError:
33  
-    try:
34  
-        import json
35  
-    except ImportError:
36  
-        try:
37  
-            # Google Appengine offers simplejson via django
38  
-            from django.utils import simplejson as json
39  
-        except ImportError:
40  
-            json_available = False
  26
+# Use the same json implementation as itsdangerous on which we
  27
+# depend anyways.
  28
+from itsdangerous import simplejson as json
41 29
 
42 30
 
43 31
 from werkzeug.datastructures import Headers
@@ -55,19 +43,10 @@
55 43
      current_app, request
56 44
 
57 45
 
58  
-def _assert_have_json():
59  
-    """Helper function that fails if JSON is unavailable."""
60  
-    if not json_available:
61  
-        raise RuntimeError('simplejson not installed')
62  
-
63  
-
64 46
 # figure out if simplejson escapes slashes.  This behavior was changed
65 47
 # from one version to another without reason.
66  
-if not json_available or '\\/' not in json.dumps('/'):
67  
-
  48
+if '\\/' not in json.dumps('/'):
68 49
     def _tojson_filter(*args, **kwargs):
69  
-        if __debug__:
70  
-            _assert_have_json()
71 50
         return json.dumps(*args, **kwargs).replace('/', '\\/')
72 51
 else:
73 52
     _tojson_filter = json.dumps
@@ -192,8 +171,6 @@ def get_current_user():
192 171
 
193 172
     .. versionadded:: 0.2
194 173
     """
195  
-    if __debug__:
196  
-        _assert_have_json()
197 174
     return current_app.response_class(json.dumps(dict(*args, **kwargs),
198 175
         indent=None if request.is_xhr else 2), mimetype='application/json')
199 176
 
125  flask/sessions.py
@@ -10,8 +10,14 @@
10 10
     :license: BSD, see LICENSE for more details.
11 11
 """
12 12
 
  13
+import hashlib
13 14
 from datetime import datetime
14  
-from werkzeug.contrib.securecookie import SecureCookie
  15
+from werkzeug.http import http_date, parse_date
  16
+from werkzeug.datastructures import CallbackDict
  17
+from .helpers import json
  18
+from . import Markup
  19
+
  20
+from itsdangerous import URLSafeTimedSerializer, BadSignature
15 21
 
16 22
 
17 23
 class SessionMixin(object):
@@ -41,11 +47,53 @@ def _set_permanent(self, value):
41 47
     modified = True
42 48
 
43 49
 
44  
-class SecureCookieSession(SecureCookie, SessionMixin):
45  
-    """Expands the session with support for switching between permanent
46  
-    and non-permanent sessions.
  50
+class TaggedJSONSerializer(object):
  51
+    """A customized JSON serializer that supports a few extra types that
  52
+    we take for granted when serializing (tuples, markup objects, datetime).
47 53
     """
48 54
 
  55
+    def dumps(self, value):
  56
+        def _tag(value):
  57
+            if isinstance(value, tuple):
  58
+                return {' t': [_tag(x) for x in value]}
  59
+            elif callable(getattr(value, '__html__', None)):
  60
+                return {' m': unicode(value.__html__())}
  61
+            elif isinstance(value, list):
  62
+                return [_tag(x) for x in value]
  63
+            elif isinstance(value, datetime):
  64
+                return {' d': http_date(value)}
  65
+            elif isinstance(value, dict):
  66
+                return dict((k, _tag(v)) for k, v in value.iteritems())
  67
+            return value
  68
+        return json.dumps(_tag(value), separators=(',', ':'))
  69
+
  70
+    def loads(self, value):
  71
+        def object_hook(obj):
  72
+            if len(obj) != 1:
  73
+                return obj
  74
+            the_key, the_value = obj.iteritems().next()
  75
+            if the_key == ' t':
  76
+                return tuple(the_value)
  77
+            elif the_key == ' m':
  78
+                return Markup(the_value)
  79
+            elif the_key == ' d':
  80
+                return parse_date(the_value)
  81
+            return obj
  82
+        return json.loads(value, object_hook=object_hook)
  83
+
  84
+
  85
+session_json_serializer = TaggedJSONSerializer()
  86
+
  87
+
  88
+class SecureCookieSession(CallbackDict, SessionMixin):
  89
+    """Baseclass for sessions based on signed cookies."""
  90
+
  91
+    def __init__(self, initial=None):
  92
+        def on_update(self):
  93
+            self.modified = True
  94
+        CallbackDict.__init__(self, initial, on_update)
  95
+        self.modified = False
  96
+
49 97
 
50 98
 class NullSession(SecureCookieSession):
51 99
     """Class used to generate nicer error messages if sessions are not
@@ -98,6 +146,13 @@ class Session(dict, SessionMixin):
98 146
     #: this type.
99 147
     null_session_class = NullSession
100 148
 
  149
+    #: A flag that indicates if the session interface is pickle based.
  150
+    #: This can be used by flask extensions to make a decision in regards
  151
+    #: to how to deal with the session object.
  152
+    #:
  153
+    #: .. versionadded:: 0.10
  154
+    pickle_based = False
  155
+
101 156
     def make_null_session(self, app):
102 157
         """Creates a null session which acts as a replacement object if the
103 158
         real session support could not be loaded due to a configuration
@@ -178,28 +233,60 @@ def save_session(self, app, session, response):
178 233
 
179 234
 
180 235
 class SecureCookieSessionInterface(SessionInterface):
181  
-    """The cookie session interface that uses the Werkzeug securecookie
182  
-    as client side session backend.
  236
+    """The default session interface that stores sessions in signed cookies
  237
+    through the :mod:`itsdangerous` module.
183 238
     """
  239
+    #: the salt that should be applied on top of the secret key for the
  240
+    #: signing of cookie based sessions.
  241
+    salt = 'cookie-session'
  242
+    #: the hash function to use for the signature.  The default is sha1
  243
+    digest_method = staticmethod(hashlib.sha1)
  244
+    #: the name of the itsdangerous supported key derivation.  The default
  245
+    #: is hmac.
  246
+    key_derivation = 'hmac'
  247
+    #: A python serializer for the payload.  The default is a compact
  248
+    #: JSON derived serializer with support for some extra Python types
  249
+    #: such as datetime objects or tuples.
  250
+    serializer = session_json_serializer
184 251
     session_class = SecureCookieSession
185 252
 
  253
+    def get_signing_serializer(self, app):
  254
+        if not app.secret_key:
  255
+            return None
  256
+        signer_kwargs = dict(
  257
+            key_derivation=self.key_derivation,
  258
+            digest_method=self.digest_method
  259
+        )
  260
+        return URLSafeTimedSerializer(app.secret_key, salt=self.salt,
  261
+                                      serializer=self.serializer,
  262
+                                      signer_kwargs=signer_kwargs)
  263
+
186 264
     def open_session(self, app, request):
187  
-        key = app.secret_key
188  
-        if key is not None:
189  
-            return self.session_class.load_cookie(request,
190  
-                                                  app.session_cookie_name,
191  
-                                                  secret_key=key)
  265
+        s = self.get_signing_serializer(app)
  266
+        if s is None:
  267
+            return None
  268
+        val = request.cookies.get(app.session_cookie_name)
  269
+        if not val:
  270
+            return self.session_class()
  271
+        max_age = app.permanent_session_lifetime.total_seconds()
  272
+        try:
  273
+            data = s.loads(val, max_age=max_age)
  274
+            return self.session_class(data)
  275
+        except BadSignature:
  276
+            return self.session_class()
192 277
 
193 278
     def save_session(self, app, session, response):
194  
-        expires = self.get_expiration_time(app, session)
195 279
         domain = self.get_cookie_domain(app)
196 280
         path = self.get_cookie_path(app)
  281
+        if not session:
  282
+            if session.modified:
  283
+                response.delete_cookie(app.session_cookie_name,
  284
+                                       domain=domain, path=path)
  285
+            return
197 286
         httponly = self.get_cookie_httponly(app)
198 287
         secure = self.get_cookie_secure(app)
199  
-        if session.modified and not session:
200  
-            response.delete_cookie(app.session_cookie_name, path=path,
201  
-                                   domain=domain)
202  
-        else:
203  
-            session.save_cookie(response, app.session_cookie_name, path=path,
204  
-                                expires=expires, httponly=httponly,
205  
-                                secure=secure, domain=domain)
  288
+        expires = self.get_expiration_time(app, session)
  289
+        val = self.get_signing_serializer(app).dumps(dict(session))
  290
+        response.set_cookie(app.session_cookie_name, val,
  291
+                            expires=expires, httponly=httponly,
  292
+                            domain=domain, path=path, secure=secure)
26  flask/testsuite/basic.py
@@ -13,6 +13,7 @@
13 13
 
14 14
 import re
15 15
 import flask
  16
+import pickle
16 17
 import unittest
17 18
 from datetime import datetime
18 19
 from threading import Thread
@@ -297,6 +298,31 @@ def dump_session_contents():
297 298
         self.assert_equal(c.get('/').data, 'None')
298 299
         self.assert_equal(c.get('/').data, '42')
299 300
 
  301
+    def test_session_special_types(self):
  302
+        app = flask.Flask(__name__)
  303
+        app.secret_key = 'development-key'
  304
+        app.testing = True
  305
+        now = datetime.utcnow().replace(microsecond=0)
  306
+
  307
+        @app.after_request
  308
+        def modify_session(response):
  309
+            flask.session['m'] = flask.Markup('Hello!')
  310
+            flask.session['dt'] = now
  311
+            flask.session['t'] = (1, 2, 3)
  312
+            return response
  313
+
  314
+        @app.route('/')
  315
+        def dump_session_contents():
  316
+            return pickle.dumps(dict(flask.session))
  317
+
  318
+        c = app.test_client()
  319
+        c.get('/')
  320
+        rv = pickle.loads(c.get('/').data)
  321
+        self.assert_equal(rv['m'], flask.Markup('Hello!'))
  322
+        self.assert_equal(type(rv['m']), flask.Markup)
  323
+        self.assert_equal(rv['dt'], now)
  324
+        self.assert_equal(rv['t'], (1, 2, 3))
  325
+
300 326
     def test_flashes(self):
301 327
         app = flask.Flask(__name__)
302 328
         app.secret_key = 'testkey'
4  flask/wrappers.py
@@ -14,7 +14,7 @@
14 14
 
15 15
 from .exceptions import JSONBadRequest
16 16
 from .debughelpers import attach_enctype_error_multidict
17  
-from .helpers import json, _assert_have_json
  17
+from .helpers import json
18 18
 from .globals import _request_ctx_stack
19 19
 
20 20
 
@@ -95,8 +95,6 @@ def json(self):
95 95
 
96 96
         This requires Python 2.6 or an installed version of simplejson.
97 97
         """
98  
-        if __debug__:
99  
-            _assert_have_json()
100 98
         if self.mimetype == 'application/json':
101 99
             request_charset = self.mimetype_params.get('charset')
102 100
             try:
3  setup.py
@@ -91,7 +91,8 @@ def run(self):
91 91
     platforms='any',
92 92
     install_requires=[
93 93
         'Werkzeug>=0.7',
94  
-        'Jinja2>=2.4'
  94
+        'Jinja2>=2.4',
  95
+        'itsdangerous>=0.17'
95 96
     ],
96 97
     classifiers=[
97 98
         'Development Status :: 4 - Beta',

0 notes on commit e1a5761

Please sign in to comment.
Something went wrong with that request. Please try again.