diff --git a/setup.py b/setup.py index 7be04e5..a430af8 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ from setuptools import setup # Metadata goes in setup.cfg. These are here for GitHub's dependency graph. -setup(name="secure-cookie", install_requires=["Werkzeug>0.15"]) +setup(name="secure-cookie", install_requires=["Werkzeug>0.15", "itsdangerous>=1.1"]) diff --git a/src/secure_cookie/cookie.py b/src/secure_cookie/cookie.py index 7030552..f27edb5 100644 --- a/src/secure_cookie/cookie.py +++ b/src/secure_cookie/cookie.py @@ -100,12 +100,15 @@ def application(request): """ import base64 import json as _json +import warnings from datetime import datetime from hashlib import sha1 as _default_hash from hmac import new as hmac from numbers import Number from time import time +from itsdangerous import Signer +from itsdangerous.exc import BadSignature from werkzeug.security import safe_str_cmp from werkzeug.urls import url_quote_plus from werkzeug.urls import url_unquote_plus @@ -280,6 +283,19 @@ def serialize(self, expires=None): if expires: self["_expires"] = _date_to_unix(expires) + result = [] + for key, value in sorted(self.items()): + result.append( + ( + "{}={}".format( + url_quote_plus(key), self.quote(value).decode("ascii") + ) + ).encode("ascii") + ) + signer = Signer(self.secret_key, digest_method=self.hash_method) + return signer.sign(b"&".join(result)) + + def _mac_serialize(self): result = [] mac = hmac(self.secret_key, None, self.hash_method) @@ -309,6 +325,52 @@ def unserialize(cls, string, secret_key): if isinstance(secret_key, text_type): secret_key = secret_key.encode("utf-8", "replace") + signer = Signer(secret_key, digest_method=cls.hash_method) + try: + serialized = signer.unsign(string) + except BadSignature: + return cls._mac_unserialize(string, secret_key) + + items = {} + for item in serialized.split(b"&"): + if b"=" not in item: + items = None + break + + key, value = item.split(b"=", 1) + # try to make the key a string + key = url_unquote_plus(key.decode("ascii")) + + try: + key = to_native(key) + except UnicodeError: + pass + + items[key] = value + + if items is not None: + try: + for key, value in items.items(): + items[key] = cls.unquote(value) + except UnquoteError: + items = () + else: + if "_expires" in items: + if time() > items["_expires"]: + items = () + else: + del items["_expires"] + else: + items = () + return cls(items, secret_key, False) + + @classmethod + def _mac_unserialize(cls, string, secret_key): + warnings.warn( + "Unserializing using the old scheme. This is deprecated and the fallback will be removed in version 2.0. Ensure cookies are re-serialized using the new ItsDangerous scheme.", # noqa + DeprecationWarning, + stacklevel=3, + ) try: base64_hash, data = string.split(b"?", 1) except (ValueError, IndexError): diff --git a/tests/test_cookie.py b/tests/test_cookie.py index 5fec8e8..384d361 100644 --- a/tests/test_cookie.py +++ b/tests/test_cookie.py @@ -1,6 +1,7 @@ import datetime import json +import pytest from werkzeug.http import parse_cookie from werkzeug.wrappers import Request from werkzeug.wrappers import Response @@ -26,7 +27,8 @@ def test_basic_support(): assert not c2.should_save assert c2 == c - c3 = SecureCookie.unserialize(s, b"wrong foo") + with pytest.warns(DeprecationWarning): + c3 = SecureCookie.unserialize(s, b"wrong foo") assert not c3.modified assert not c3.new assert c3 == {} @@ -51,6 +53,20 @@ def test_expire_support(): assert "x" not in c3 +def test_hmac_serialization_support(): + c = SecureCookie(secret_key=b"foo") + c["x"] = 42 + s = c._mac_serialize() + + with pytest.warns(DeprecationWarning): + c2 = SecureCookie.unserialize(s, b"foo") + assert c is not c2 + assert not c2.new + assert not c2.modified + assert not c2.should_save + assert c2 == c + + def test_wrapper_support(): req = Request.from_values() resp = Response()