Repository: pallets/werkzeug

Issue:
SecureCookie with 'serialization_method = json' throws an exception on Python 3.4
Python version: 3.4.3
Werkzeug version: 0.11.10

Simple snippet, taken from the example page (http://werkzeug.pocoo.org/docs/0.11/contrib/securecookie/#security):

```
#!/usr/bin/env python

import json
from werkzeug.contrib.securecookie import SecureCookie

class JSONSecureCookie(SecureCookie):
        serialization_method = json

c = JSONSecureCookie({1: 2}, b"key")
print(c.serialize())
```

...returns an error:

```
Traceback (most recent call last):
  File "test_secure_cookie.py", line 10, in <module>
    print(c.serialize())
  File "/home/tonyo/reps/github/werkzeug/werkzeug/contrib/securecookie.py", line 224, in serialize
    self.quote(value).decode('ascii')
  File "/home/tonyo/reps/github/werkzeug/werkzeug/contrib/securecookie.py", line 183, in quote
    value = b''.join(base64.b64encode(value).splitlines()).strip()
  File "/home/tonyo/reps/github/werkzeug/env3/lib/python3.4/base64.py", line 62, in b64encode
    encoded = binascii.b2a_base64(s)[:-1]
TypeError: 'str' does not support the buffer interface

```

Looking at `SecureCookie.quote()` (https://github.com/pallets/werkzeug/blob/5a2bf35441006d832ab1ed5a31963cbc366c99ac/werkzeug/contrib/securecookie.py#L174) we see, that json.dumps() returns a string object, while base64.b64encode() accepts a byte sequence.

While this issue with encoding in `SecureCookie.quote()` is fixable by some variation of `value = value.encode('utf-8')`, the real problem in my opinion is unserializing (unquoting, to be more precise). In `SecureCookie.unquote()` `value` is first decoded via `value = base64.b64decode(value)`, and then passed to `cls.serialization_method.loads(value)` which in case of 'json' accepts string, not bytes. Therefore, we'll get something similar to:

```
>>> json.loads(b'')                                              
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.4/json/__init__.py", line 312, in loads
    s.__class__.__name__))
TypeError: the JSON object must be str, not 'bytes'
```

As a solution, if one wants to use 'json' as a serialization method, 'quote' and 'unquote' can be fully overridden:

```
class JSONSecureCookie(SecureCookie):
    serialization_method = json

    @classmethod
    def quote(cls, value):
        if cls.serialization_method is not None:
            value = cls.serialization_method.dumps(value)
            ### Added line
            value = value.encode('utf-8')
        if cls.quote_base64:
            value = b''.join(base64.b64encode(value).splitlines()).strip()
        return value

    @classmethod
    def unquote(cls, value):
        try:
            if cls.quote_base64:
                value = base64.b64decode(value)
        ### Added line 
            value = str(value, 'utf-8')
            if cls.serialization_method is not None:
                value = cls.serialization_method.loads(value)
            return value
        except Exception:
            raise UnquoteError()

```

However, this looks a bit ugly. 

Is there a way to fix this, so SecureCookie can simply work with 'json' as a serializer?



Discussion:
See #4, `werkzeug.contrib` needs to go.  This (and probably `werkzeug.contrib.sessions`) could be extracted to a package that uses [itsdangerous](https://github.com/pallets/itsdangerous), rather than duplicating that work.  See [`flask.sessions`](https://github.com/pallets/flask/blob/master/flask/sessions.py) for example.

Is there a fix now?
`flask.session` isn't documented and complete.
Like any issue, you will know it's resolved when it's no longer open. If you'd like to help resolve it faster, contributions are welcome.

Flask's sessions are well documented.
Oh sorry, yes flask.sessions maybe well documented but i don't use flask.
I only use werkzeug on my project.
I think this should be fixed even if flask offers better solutions.
> If you'd like to help resolve it faster, contributions are welcome.

I'm not suggesting you use Flask, I was using that to demonstrate that we're duplicating work.
I think what I'd like to see is getting this out of contrib and into Werkzeug, matching Flask's more developed implementation but without a dependency on itsdangerous. Then use it in Flask with a few tweaks.

Resolving patch:
diff --git a/werkzeug/contrib/securecookie.py b/werkzeug/contrib/securecookie.py
--- a/werkzeug/contrib/securecookie.py
+++ b/werkzeug/contrib/securecookie.py
@@ -90,6 +90,7 @@ def application(environ, start_response):
 """
 import pickle
 import base64
+import warnings
 from hmac import new as hmac
 from time import time
 from hashlib import sha1 as _default_hash
@@ -139,8 +140,11 @@ class SecureCookie(ModificationTrackingDict):
     #: as a function.
     hash_method = staticmethod(_default_hash)
 
-    #: the module used for serialization.  Unless overriden by subclasses
-    #: the standard pickle module is used.
+    #: The module used for serialization. Should have a ``dumps`` and a
+    #: ``loads`` method that takes bytes. The default is :mod:`pickle`.
+    #:
+    #: .. versionchanged:: 0.15
+    #:     The default of ``pickle`` will change to :mod:`json` in 1.0.
     serialization_method = pickle
 
     #: if the contents should be base64 quoted.  This can be disabled if the
@@ -156,6 +160,13 @@ def __init__(self, data=None, secret_key=None, new=True):
         self.secret_key = secret_key
         self.new = new
 
+        if self.serialization_method is pickle:
+            warnings.warn(
+                'The default SecureCookie.serialization_method will change from pickle'
+                ' to json in 1.0. To upgrade existing tokens, override unquote to try'
+                ' pickle if json fails.'
+            )
+
     def __repr__(self):
         return '<%s %s%s>' % (
             self.__class__.__name__,
@@ -180,7 +191,9 @@ def quote(cls, value):
         if cls.serialization_method is not None:
             value = cls.serialization_method.dumps(value)
         if cls.quote_base64:
-            value = b''.join(base64.b64encode(value).splitlines()).strip()
+            value = b''.join(
+                base64.b64encode(to_bytes(value, "utf8")).splitlines()
+            ).strip()
         return value
 
     @classmethod


Test patch:
diff --git a/tests/contrib/test_securecookie.py b/tests/contrib/test_securecookie.py
--- a/tests/contrib/test_securecookie.py
+++ b/tests/contrib/test_securecookie.py
@@ -8,7 +8,10 @@
     :copyright: (c) 2014 by Armin Ronacher.
     :license: BSD, see LICENSE for more details.
 """
+import json
+import pytest
 
+from werkzeug._compat import to_native
 from werkzeug.utils import parse_cookie
 from werkzeug.wrappers import Request, Response
 from werkzeug.contrib.securecookie import SecureCookie
@@ -56,3 +59,25 @@ def test_wrapper_support():
     c2 = SecureCookie.load_cookie(req, secret_key=b'foo')
     assert not c2.new
     assert c2 == c
+
+
+def test_pickle_deprecated():
+    with pytest.warns(UserWarning):
+        SecureCookie({"foo": "bar"}, "secret").serialize()
+
+
+def test_json():
+    class JSONCompat(object):
+        dumps = staticmethod(json.dumps)
+
+        @staticmethod
+        def loads(s):
+            # json on Python < 3.6 fails on bytes
+            return json.loads(to_native(s, "utf8"))
+
+    class JSONSecureCookie(SecureCookie):
+        serialization_method = JSONCompat
+
+    secure = JSONSecureCookie({"foo": "bar"}, "secret").serialize()
+    data = JSONSecureCookie.unserialize(secure, "secret")
+    assert data == {"foo": "bar"}