From 5cc157a749d4720144f4156c1d9fbd673e1dc68d Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 18:03:04 +0200 Subject: Added test for unicode basic authentication --- pyramid/tests/test_authentication.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index 123e4f9f5..c9313e0c6 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1287,6 +1287,16 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): policy = self._makeOne(check) self.assertEqual(policy.authenticated_userid(request), 'chrisr') + def test_authenticated_userid_utf8(self): + import base64 + request = testing.DummyRequest() + inputs = u'm\xf6rk\xf6:m\xf6rk\xf6password' + request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) + def check(username, password, request): + return [] + policy = self._makeOne(check) + self.assertEqual(policy.authenticated_userid(request), u'm\xf6rk\xf6') + def test_unauthenticated_userid_invalid_payload(self): import base64 request = testing.DummyRequest() -- cgit v1.2.3 From 3ac0b98a980a930ec9e6a6d4d364eb8af505a77c Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 20:36:47 +0200 Subject: Python 3 compatible string handling --- pyramid/tests/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index c9313e0c6..e696a5754 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1290,12 +1290,12 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = u'm\xf6rk\xf6:m\xf6rk\xf6password' + inputs = 'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), u'm\xf6rk\xf6') + self.assertEqual(policy.authenticated_userid(request), 'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From dc8533aad4d432817b52f158804f0b6a81fab374 Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 20:59:00 +0200 Subject: Switched to bytes --- pyramid/tests/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index e696a5754..97b2b1a7c 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1290,12 +1290,12 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = 'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), 'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + self.assertEqual(policy.authenticated_userid(request), b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From 3a6cbcce80b5292082b2f4e2f920d2df127e2774 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 3 Oct 2013 03:32:28 -0500 Subject: modification to the unencrypted cookie to use a clearer api improved the signing to use a derived key based on a random salt, and upgraded the hash from sha1 to sha512. Finally the entire result is b64 instead of just the payload. --- pyramid/session.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 3708ef879..db86ca32b 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -1,3 +1,4 @@ +import hashlib from hashlib import sha1 import base64 import binascii @@ -119,7 +120,7 @@ def UnencryptedCookieSessionFactoryConfig( cookie_max_age=None, cookie_path='/', cookie_domain=None, - cookie_secure=False, + cookie_secure=False, cookie_httponly=False, cookie_on_exception=True, signed_serialize=signed_serialize, @@ -182,7 +183,7 @@ def UnencryptedCookieSessionFactoryConfig( """ @implementer(ISession) - class UnencryptedCookieSessionFactory(dict): + class SignedCookieSession(dict): """ Dictionary-like session object """ # configuration parameters @@ -306,12 +307,146 @@ def UnencryptedCookieSessionFactoryConfig( response.set_cookie( self._cookie_name, value=cookieval, - max_age = self._cookie_max_age, - path = self._cookie_path, - domain = self._cookie_domain, - secure = self._cookie_secure, - httponly = self._cookie_httponly, + max_age=self._cookie_max_age, + path=self._cookie_path, + domain=self._cookie_domain, + secure=self._cookie_secure, + httponly=self._cookie_httponly, ) return True - return UnencryptedCookieSessionFactory + return SignedCookieSession + +def SignedCookieSessionFactory( + secret, + hashalg='sha512', + timeout=1200, + cookie_name='session', + cookie_max_age=None, + cookie_path='/', + cookie_domain=None, + cookie_secure=False, + cookie_httponly=False, + cookie_on_exception=True, + serializer=None, + ): + """ + Configure a :term:`session factory` which will provide signed + cookie-based sessions. The return value of this + function is a :term:`session factory`, which may be provided as + the ``session_factory`` argument of a + :class:`pyramid.config.Configurator` constructor, or used + as the ``session_factory`` argument of the + :meth:`pyramid.config.Configurator.set_session_factory` + method. + + The session factory returned by this function will create sessions + which are limited to storing fewer than 4000 bytes of data (as the + payload must fit into a single cookie). + + Parameters: + + ``secret`` + A string which is used to sign the cookie. + + ``hashalg`` + The HMAC digest algorithm to use for signing. The algorithm must be + supported by the :mod:`hashlib` library. Default: ``'sha512'``. + + ``timeout`` + A number of seconds of inactivity before a session times out. + Default: 1200. + + ``cookie_name`` + The name of the cookie used for sessioning. Default: ``'session'``. + + ``cookie_max_age`` + The maximum age of the cookie used for sessioning (in seconds). + Default: ``None`` (browser scope). + + ``cookie_path`` + The path used for the session cookie. Default: ``'/'``. + + ``cookie_domain`` + The domain used for the session cookie. Default: ``None`` (no domain). + + ``cookie_secure`` + The 'secure' flag of the session cookie. Default: ``False``. + + ``cookie_httponly`` + The 'httpOnly' flag of the session cookie. Default: ``False``. + + ``cookie_on_exception`` + If ``True``, set a session cookie even if an exception occurs + while rendering a view. Default: ``True``. + + ``serializer`` + An object which can convert data between Python objects and bytestrings. + - ``loads`` should accept a bytestring and return a Python object. + - ``dumps`` should accept a Python object and return a bytestring. + The default implementation uses Python's pickle module. + """ + + if serializer is None: + serializer = _PickleSerializer() + + signer = _SignedSerializer(hashalg, serializer) + + return UnencryptedCookieSessionFactoryConfig( + secret, + timeout=timeout, + cookie_name=cookie_name, + cookie_max_age=cookie_max_age, + cookie_path=cookie_path, + cookie_domain=cookie_domain, + cookie_secure=cookie_secure, + cookie_httponly=cookie_httponly, + cookie_on_exception=cookie_on_exception, + signed_serialize=signer.dumps, + signed_deserialize=signer.loads, + ) + +class _PickleSerializer(object): + def dumps(self, value): + return pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + + def loads(self, value): + return pickle.loads(value) + +class _SignedSerializer(object): + def __init__(self, hashalg, serializer): + self.digestmod = lambda: hashlib.new(hashalg) + self.digest_size = self.digestmod().digest_size + self.salt_size = 8 + self.serializer = serializer + + def derive_key(self, secret, salt): + return hmac.new(secret, salt, self.digestmod).digest() + + def dumps(self, appstruct, secret): + salt = os.urandom(self.salt_size) + derived_secret = self.derive_key(secret, salt) + cstruct = self.serializer.dumps(appstruct) + sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + return native_(base64.b64encode(cstruct + salt + sig)) + + def loads(self, bstruct, secret): + try: + fstruct = base64.b64decode(bytes_(bstruct)) + except (binascii.Error, TypeError) as e: + raise ValueError('Badly formed base64 data: %s' % e) + + cstruct_size = len(fstruct) - self.salt_size - self.digest_size + if cstruct_size < 0: + raise ValueError('Input is too short.') + + cstruct = fstruct[:cstruct_size] + salt = fstruct[cstruct_size:self.salt_size] + expected_sig = fstruct[:-self.digest_size] + + derived_secret = self.derive_key(secret, salt) + sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + if strings_differ(sig, expected_sig): + raise ValueError('Invalid signature') + + return self.serializer.loads(cstruct) -- cgit v1.2.3 From 4fade654a42b88ea1f042af974f76b97d326c455 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 5 Oct 2013 03:48:52 -0500 Subject: introduce SignedCookieSessionFactory - Break apart UnencryptedCookieSessionFactoryConfig into a BaseCookieSessionFactory. - Add support for reissue_time in the base. Set the unencrypted class to use reissue_time=0 for bw-compat. - Add SignedCookieSessionFactory which wraps the base in a serializer that uses signing via a sha512+hmac with a secret derived using an 8-byte random salt. --- pyramid/session.py | 400 ++++++++++++++++++++++++++++-------------- pyramid/tests/test_session.py | 290 ++++++++++++++++++++++++------ 2 files changed, 508 insertions(+), 182 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index db86ca32b..e6635ca1b 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -21,20 +21,26 @@ from pyramid.interfaces import ISession from pyramid.util import strings_differ def manage_accessed(wrapped): - """ Decorator which causes a cookie to be set when a wrapped - method is called""" + """ Decorator which causes a cookie to be renewed when an accessor + method is called.""" def accessed(session, *arg, **kw): - session.accessed = int(time.time()) - if not session._dirty: - session._dirty = True - def set_cookie_callback(request, response): - session._set_cookie(response) - session.request = None # explicitly break cycle for gc - session.request.add_response_callback(set_cookie_callback) + session.accessed = now = int(time.time()) + if now - session.renewed > session._reissue_time: + session.changed() return wrapped(session, *arg, **kw) accessed.__doc__ = wrapped.__doc__ return accessed +def manage_changed(wrapped): + """ Decorator which causes a cookie to be set when a setter method + is called.""" + def changed(session, *arg, **kw): + session.accessed = int(time.time()) + session.changed() + return wrapped(session, *arg, **kw) + changed.__doc__ = wrapped.__doc__ + return changed + def signed_serialize(data, secret): """ Serialize any pickleable structure (``data``) and sign it using the ``secret`` (must be a string). Return the @@ -67,13 +73,13 @@ def signed_deserialize(serialized, secret, hmac=hmac): """ # hmac parameterized only for unit tests try: - input_sig, pickled = (serialized[:40], + input_sig, pickled = (bytes_(serialized[:40]), base64.b64decode(bytes_(serialized[40:]))) except (binascii.Error, TypeError) as e: # Badly formed data can make base64 die raise ValueError('Badly formed base64 data: %s' % e) - sig = hmac.new(bytes_(secret), pickled, sha1).hexdigest() + sig = bytes_(hmac.new(bytes_(secret), pickled, sha1).hexdigest()) # Avoid timing attacks (see # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) @@ -113,89 +119,108 @@ def check_csrf_token(request, return False return True -def UnencryptedCookieSessionFactoryConfig( - secret, - timeout=1200, +def BaseCookieSessionFactory( + serializer, cookie_name='session', - cookie_max_age=None, - cookie_path='/', - cookie_domain=None, - cookie_secure=False, - cookie_httponly=False, - cookie_on_exception=True, - signed_serialize=signed_serialize, - signed_deserialize=signed_deserialize, + max_age=None, + path='/', + domain=None, + secure=False, + httponly=False, + timeout=1200, + reissue_time=0, + set_on_exception=True, ): """ - Configure a :term:`session factory` which will provide unencrypted - (but signed) cookie-based sessions. The return value of this - function is a :term:`session factory`, which may be provided as - the ``session_factory`` argument of a - :class:`pyramid.config.Configurator` constructor, or used + Configure a :term:`session factory` which will provide cookie-based + sessions. The return value of this function is a + :term:`session factory`, which may be provided as the ``session_factory`` + argument of a :class:`pyramid.config.Configurator` constructor, or used as the ``session_factory`` argument of the - :meth:`pyramid.config.Configurator.set_session_factory` - method. + :meth:`pyramid.config.Configurator.set_session_factory` method. The session factory returned by this function will create sessions which are limited to storing fewer than 4000 bytes of data (as the payload must fit into a single cookie). - Parameters: + .. warning: - ``secret`` - A string which is used to sign the cookie. + This class provides no protection from tampering and is only intended + to be used by framework authors to create their own cookie-based + session factories. - ``timeout`` - A number of seconds of inactivity before a session times out. + Parameters: + + ``serializer`` + An object with 2 methods, ``loads`` and ``dumps``, which will be used + to perform serialization and deserialization. + - ``dumps(value)`` should accept a Python object and return a + bytestring which can later be deserialized with ``loads``. + - ``loads(value)`` should expect to receive a bytestring, generated by + ``dumps`` and return a Python object. ``cookie_name`` - The name of the cookie used for sessioning. + The name of the cookie used for sessioning. Default: ``'session'``. - ``cookie_max_age`` + ``max_age`` The maximum age of the cookie used for sessioning (in seconds). Default: ``None`` (browser scope). - ``cookie_path`` - The path used for the session cookie. + ``path`` + The path used for the session cookie. Default: ``'/'``. - ``cookie_domain`` + ``domain`` The domain used for the session cookie. Default: ``None`` (no domain). - ``cookie_secure`` - The 'secure' flag of the session cookie. + ``secure`` + The 'secure' flag of the session cookie. Default: ``False``. - ``cookie_httponly`` - The 'httpOnly' flag of the session cookie. + ``httponly`` + Hide the cookie from Javascript by setting the 'HttpOnly' flag of the + session cookie. Default: ``False``. - ``cookie_on_exception`` + ``timeout`` + A number of seconds of inactivity before a session times out. If + ``None`` then the cookie never expires. Default: 1200. + + ``reissue_time`` + The number of seconds that must pass before the cookie is automatically + reissued as the result of a request which accesses the session. The + duration is measured as the number of seconds since the last session + cookie was issued and 'now'. If this value is ``0``, a new cookie + will be reissued on every request accesses the session. If ``None`` + then the cookie's lifetime will never be extended. + + A good rule of thumb: if you want auto-expired cookies based on + inactivity: set the ``timeout`` value to 1200 (20 mins) and set the + ``reissue_time`` value to perhaps a tenth of the ``timeout`` value + (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower + than the ``reissue_time`` value, as the ticket will never be reissued. + However, such a configuration is not explicitly prevented. + + Default: ``0``. + + ``set_on_exception`` If ``True``, set a session cookie even if an exception occurs - while rendering a view. - - ``signed_serialize`` - A callable which takes more or less arbitrary Python data structure and - a secret and returns a signed serialization in bytes. - Default: ``signed_serialize`` (using pickle). + while rendering a view. Default: ``True``. - ``signed_deserialize`` - A callable which takes a signed and serialized data structure in bytes - and a secret and returns the original data structure if the signature - is valid. Default: ``signed_deserialize`` (using pickle). + .. versionadded: 1.5a3 """ @implementer(ISession) - class SignedCookieSession(dict): + class CookieSession(dict): """ Dictionary-like session object """ # configuration parameters _cookie_name = cookie_name - _cookie_max_age = cookie_max_age - _cookie_path = cookie_path - _cookie_domain = cookie_domain - _cookie_secure = cookie_secure - _cookie_httponly = cookie_httponly - _cookie_on_exception = cookie_on_exception - _secret = secret + _cookie_max_age = max_age + _cookie_path = path + _cookie_domain = domain + _cookie_secure = secure + _cookie_httponly = httponly + _cookie_on_exception = set_on_exception _timeout = timeout + _reissue_time = reissue_time # dirty flag _dirty = False @@ -203,33 +228,40 @@ def UnencryptedCookieSessionFactoryConfig( def __init__(self, request): self.request = request now = time.time() - created = accessed = now + created = renewed = now new = True value = None state = {} cookieval = request.cookies.get(self._cookie_name) if cookieval is not None: try: - value = signed_deserialize(cookieval, self._secret) + value = serializer.loads(bytes_(cookieval)) except ValueError: value = None if value is not None: - accessed, created, state = value - new = False - if now - accessed > self._timeout: + try: + renewed, created, state = value + new = False + if now - renewed > self._timeout: + state = {} + except TypeError: state = {} self.created = created - self.accessed = accessed + self.accessed = renewed + self.renewed = renewed self.new = new dict.__init__(self, state) # ISession methods def changed(self): - """ This is intentionally a noop; the session is - serialized on every access, so unnecessary""" - pass + if not self._dirty: + self._dirty = True + def set_cookie_callback(request, response): + self._set_cookie(response) + self.request = None # explicitly break cycle for gc + self.request.add_response_callback(set_cookie_callback) def invalidate(self): self.clear() # XXX probably needs to unset cookie @@ -251,22 +283,22 @@ def UnencryptedCookieSessionFactoryConfig( has_key = manage_accessed(dict.has_key) # modifying dictionary methods - clear = manage_accessed(dict.clear) - update = manage_accessed(dict.update) - setdefault = manage_accessed(dict.setdefault) - pop = manage_accessed(dict.pop) - popitem = manage_accessed(dict.popitem) - __setitem__ = manage_accessed(dict.__setitem__) - __delitem__ = manage_accessed(dict.__delitem__) + clear = manage_changed(dict.clear) + update = manage_changed(dict.update) + setdefault = manage_changed(dict.setdefault) + pop = manage_changed(dict.pop) + popitem = manage_changed(dict.popitem) + __setitem__ = manage_changed(dict.__setitem__) + __delitem__ = manage_changed(dict.__delitem__) # flash API methods - @manage_accessed + @manage_changed def flash(self, msg, queue='', allow_duplicate=True): storage = self.setdefault('_f_' + queue, []) if allow_duplicate or (msg not in storage): storage.append(msg) - @manage_accessed + @manage_changed def pop_flash(self, queue=''): storage = self.pop('_f_' + queue, []) return storage @@ -277,7 +309,7 @@ def UnencryptedCookieSessionFactoryConfig( return storage # CSRF API methods - @manage_accessed + @manage_changed def new_csrf_token(self): token = text_(binascii.hexlify(os.urandom(20))) self['_csrft_'] = token @@ -296,9 +328,9 @@ def UnencryptedCookieSessionFactoryConfig( exception = getattr(self.request, 'exception', None) if exception is not None: # dont set a cookie during exceptions return False - cookieval = signed_serialize( - (self.accessed, self.created, dict(self)), self._secret - ) + cookieval = native_(serializer.dumps( + (self.accessed, self.created, dict(self)) + )) if len(cookieval) > 4064: raise ValueError( 'Cookie value is too long to store (%s bytes)' % @@ -315,11 +347,10 @@ def UnencryptedCookieSessionFactoryConfig( ) return True - return SignedCookieSession + return CookieSession -def SignedCookieSessionFactory( +def UnencryptedCookieSessionFactoryConfig( secret, - hashalg='sha512', timeout=1200, cookie_name='session', cookie_max_age=None, @@ -328,6 +359,97 @@ def SignedCookieSessionFactory( cookie_secure=False, cookie_httponly=False, cookie_on_exception=True, + signed_serialize=signed_serialize, + signed_deserialize=signed_deserialize, + ): + """ + Configure a :term:`session factory` which will provide unencrypted + (but signed) cookie-based sessions. The return value of this + function is a :term:`session factory`, which may be provided as + the ``session_factory`` argument of a + :class:`pyramid.config.Configurator` constructor, or used + as the ``session_factory`` argument of the + :meth:`pyramid.config.Configurator.set_session_factory` + method. + + The session factory returned by this function will create sessions + which are limited to storing fewer than 4000 bytes of data (as the + payload must fit into a single cookie). + + Parameters: + + ``secret`` + A string which is used to sign the cookie. + + ``timeout`` + A number of seconds of inactivity before a session times out. + + ``cookie_name`` + The name of the cookie used for sessioning. + + ``cookie_max_age`` + The maximum age of the cookie used for sessioning (in seconds). + Default: ``None`` (browser scope). + + ``cookie_path`` + The path used for the session cookie. + + ``cookie_domain`` + The domain used for the session cookie. Default: ``None`` (no domain). + + ``cookie_secure`` + The 'secure' flag of the session cookie. + + ``cookie_httponly`` + The 'httpOnly' flag of the session cookie. + + ``cookie_on_exception`` + If ``True``, set a session cookie even if an exception occurs + while rendering a view. + + ``signed_serialize`` + A callable which takes more or less arbitrary Python data structure and + a secret and returns a signed serialization in bytes. + Default: ``signed_serialize`` (using pickle). + + ``signed_deserialize`` + A callable which takes a signed and serialized data structure in bytes + and a secret and returns the original data structure if the signature + is valid. Default: ``signed_deserialize`` (using pickle). + """ + + class _Serializer(object): + def dumps(self, appstruct): + return signed_serialize(appstruct, secret) + + def loads(self, bstruct): + return signed_deserialize(bstruct, secret) + + return BaseCookieSessionFactory( + _Serializer(), + cookie_name=cookie_name, + max_age=cookie_max_age, + path=cookie_path, + domain=cookie_domain, + secure=cookie_secure, + httponly=cookie_httponly, + timeout=timeout, + reissue_time=0, # to keep session.accessed == session.renewed + set_on_exception=cookie_on_exception, + ) + +def SignedCookieSessionFactory( + secret, + cookie_name='session', + max_age=None, + path='/', + domain=None, + secure=False, + httponly=False, + set_on_exception=True, + timeout=1200, + reissue_time=0, + hashalg='sha512', serializer=None, ): """ @@ -353,86 +475,106 @@ def SignedCookieSessionFactory( The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. - ``timeout`` - A number of seconds of inactivity before a session times out. - Default: 1200. - ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. - ``cookie_max_age`` + ``max_age`` The maximum age of the cookie used for sessioning (in seconds). Default: ``None`` (browser scope). - ``cookie_path`` + ``path`` The path used for the session cookie. Default: ``'/'``. - ``cookie_domain`` + ``domain`` The domain used for the session cookie. Default: ``None`` (no domain). - ``cookie_secure`` + ``secure`` The 'secure' flag of the session cookie. Default: ``False``. - ``cookie_httponly`` - The 'httpOnly' flag of the session cookie. Default: ``False``. + ``httponly`` + Hide the cookie from Javascript by setting the 'HttpOnly' flag of the + session cookie. Default: ``False``. - ``cookie_on_exception`` + ``timeout`` + A number of seconds of inactivity before a session times out. If + ``None`` then the cookie never expires. Default: 1200. + + ``reissue_time`` + The number of seconds that must pass before the cookie is automatically + reissued as the result of a request which accesses the session. The + duration is measured as the number of seconds since the last session + cookie was issued and 'now'. If this value is ``0``, a new cookie + will be reissued on every request accesses the session. If ``None`` + then the cookie's lifetime will never be extended. + + A good rule of thumb: if you want auto-expired cookies based on + inactivity: set the ``timeout`` value to 1200 (20 mins) and set the + ``reissue_time`` value to perhaps a tenth of the ``timeout`` value + (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower + than the ``reissue_time`` value, as the ticket will never be reissued. + However, such a configuration is not explicitly prevented. + + Default: ``0``. + + ``set_on_exception`` If ``True``, set a session cookie even if an exception occurs while rendering a view. Default: ``True``. ``serializer`` - An object which can convert data between Python objects and bytestrings. - - ``loads`` should accept a bytestring and return a Python object. - - ``dumps`` should accept a Python object and return a bytestring. - The default implementation uses Python's pickle module. + An object with 2 methods, ``loads`` and ``dumps``, which will be used + to perform serialization and deserialization. The value generated from + serialization will be cryptographically signed to prevent tampering. + A ``ValueError`` should be raised if deserialization fails. + + .. versionadded: 1.5a3 """ if serializer is None: serializer = _PickleSerializer() - signer = _SignedSerializer(hashalg, serializer) + signed_serializer = _SignedSerializer(secret, hashalg, serializer) - return UnencryptedCookieSessionFactoryConfig( - secret, - timeout=timeout, + return BaseCookieSessionFactory( + signed_serializer, cookie_name=cookie_name, - cookie_max_age=cookie_max_age, - cookie_path=cookie_path, - cookie_domain=cookie_domain, - cookie_secure=cookie_secure, - cookie_httponly=cookie_httponly, - cookie_on_exception=cookie_on_exception, - signed_serialize=signer.dumps, - signed_deserialize=signer.loads, + max_age=max_age, + path=path, + domain=domain, + secure=secure, + httponly=httponly, + timeout=timeout, + reissue_time=reissue_time, + set_on_exception=set_on_exception, ) class _PickleSerializer(object): - def dumps(self, value): - return pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + def dumps(self, appstruct): + return pickle.dumps(appstruct, pickle.HIGHEST_PROTOCOL) - def loads(self, value): - return pickle.loads(value) + def loads(self, bstruct): + return pickle.loads(bstruct) class _SignedSerializer(object): - def __init__(self, hashalg, serializer): + def __init__(self, secret, hashalg, serializer): + self.secret = secret self.digestmod = lambda: hashlib.new(hashalg) self.digest_size = self.digestmod().digest_size self.salt_size = 8 self.serializer = serializer - def derive_key(self, secret, salt): - return hmac.new(secret, salt, self.digestmod).digest() + def derive_key(self, salt): + return hmac.new(self.secret, salt, self.digestmod).digest() - def dumps(self, appstruct, secret): + def dumps(self, appstruct): salt = os.urandom(self.salt_size) - derived_secret = self.derive_key(secret, salt) + derived_secret = self.derive_key(salt) cstruct = self.serializer.dumps(appstruct) sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() - return native_(base64.b64encode(cstruct + salt + sig)) + return base64.b64encode(cstruct + salt + sig) - def loads(self, bstruct, secret): + def loads(self, bstruct): try: - fstruct = base64.b64decode(bytes_(bstruct)) + fstruct = base64.b64decode(bstruct) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) @@ -441,10 +583,10 @@ class _SignedSerializer(object): raise ValueError('Input is too short.') cstruct = fstruct[:cstruct_size] - salt = fstruct[cstruct_size:self.salt_size] - expected_sig = fstruct[:-self.digest_size] + salt = fstruct[cstruct_size:cstruct_size + self.salt_size] + expected_sig = fstruct[-self.digest_size:] - derived_secret = self.derive_key(secret, salt) + derived_secret = self.derive_key(salt) sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() if strings_differ(sig, expected_sig): raise ValueError('Invalid signature') diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 35e2b5c27..0e1ed78a6 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -1,10 +1,8 @@ +import json import unittest from pyramid import testing -class TestUnencryptedCookieSession(unittest.TestCase): - def _makeOne(self, request, **kw): - from pyramid.session import UnencryptedCookieSessionFactoryConfig - return UnencryptedCookieSessionFactoryConfig('secret', **kw)(request) +class SharedCookieSessionTests(object): def test_ctor_no_cookie(self): request = testing.DummyRequest() @@ -18,36 +16,47 @@ class TestUnencryptedCookieSession(unittest.TestCase): session = self._makeOne(request) verifyObject(ISession, session) - def _serialize(self, accessed, state, secret='secret'): - from pyramid.session import signed_serialize - return signed_serialize((accessed, accessed, state), secret) - def test_ctor_with_cookie_still_valid(self): import time request = testing.DummyRequest() - cookieval = self._serialize(time.time(), {'state':1}) + cookieval = self._serialize((time.time(), 0, {'state': 1})) request.cookies['session'] = cookieval session = self._makeOne(request) self.assertEqual(dict(session), {'state':1}) - + def test_ctor_with_cookie_expired(self): request = testing.DummyRequest() - cookieval = self._serialize(0, {'state':1}) + cookieval = self._serialize((0, 0, {'state': 1})) request.cookies['session'] = cookieval session = self._makeOne(request) self.assertEqual(dict(session), {}) - def test_ctor_with_bad_cookie(self): + def test_ctor_with_bad_cookie_cannot_deserialize(self): + request = testing.DummyRequest() + request.cookies['session'] = 'abc' + session = self._makeOne(request) + self.assertEqual(dict(session), {}) + + def test_ctor_with_bad_cookie_not_tuple(self): request = testing.DummyRequest() - cookieval = 'abc' + cookieval = self._serialize('abc') request.cookies['session'] = cookieval session = self._makeOne(request) self.assertEqual(dict(session), {}) + def test_timeout(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time() - 5, 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, timeout=1) + self.assertEqual(dict(session), {}) + def test_changed(self): request = testing.DummyRequest() session = self._makeOne(request) self.assertEqual(session.changed(), None) + self.assertTrue(session._dirty) def test_invalidate(self): request = testing.DummyRequest() @@ -56,6 +65,15 @@ class TestUnencryptedCookieSession(unittest.TestCase): self.assertEqual(session.invalidate(), None) self.assertFalse('a' in session) + def test_reissue_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time() - 2, 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(session['state'], 1) + self.assertTrue(session._dirty) + def test__set_cookie_on_exception(self): request = testing.DummyRequest() request.exception = True @@ -95,16 +113,16 @@ class TestUnencryptedCookieSession(unittest.TestCase): request = testing.DummyRequest() request.exception = None session = self._makeOne(request, - cookie_name = 'abc', - cookie_path = '/foo', - cookie_domain = 'localhost', - cookie_secure = True, - cookie_httponly = True, + cookie_name='abc', + path='/foo', + domain='localhost', + secure=True, + httponly=True, ) session['abc'] = 'x' response = Response() self.assertEqual(session._set_cookie(response), True) - cookieval= response.headerlist[-1][1] + cookieval = response.headerlist[-1][1] val, domain, path, secure, httponly = [x.strip() for x in cookieval.split(';')] self.assertTrue(val.startswith('abc=')) @@ -205,6 +223,110 @@ class TestUnencryptedCookieSession(unittest.TestCase): self.assertTrue(token) self.assertTrue('_csrft_' in session) + def test_no_set_cookie_with_exception(self): + import webob + request = testing.DummyRequest() + request.exception = True + session = self._makeOne(request, set_on_exception=False) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertFalse('Set-Cookie' in dict(response.headerlist)) + + def test_set_cookie_with_exception(self): + import webob + request = testing.DummyRequest() + request.exception = True + session = self._makeOne(request) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertTrue('Set-Cookie' in dict(response.headerlist)) + + def test_cookie_is_set(self): + import webob + request = testing.DummyRequest() + session = self._makeOne(request) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertTrue('Set-Cookie' in dict(response.headerlist)) + +class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import BaseCookieSessionFactory + return BaseCookieSessionFactory(DummySerializer(), **kw)(request) + + def _serialize(self, value): + return json.dumps(value) + + def test_reissue_not_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, reissue_time=1) + self.assertEqual(session['state'], 1) + self.assertFalse(session._dirty) + +class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import SignedCookieSessionFactory + return SignedCookieSessionFactory('secret', **kw)(request) + + def _serialize(self, value): + from pyramid.session import _SignedSerializer, _PickleSerializer + serializer = _PickleSerializer() + serializer = _SignedSerializer('secret', 'sha512', serializer) + return serializer.dumps(value) + + def test_reissue_not_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, reissue_time=1) + self.assertEqual(session['state'], 1) + self.assertFalse(session._dirty) + + def test_custom_serializer(self): + import time + from pyramid.session import _SignedSerializer + serializer = DummySerializer() + signer = _SignedSerializer('secret', 'sha512', serializer=serializer) + cookieval = signer.dumps((time.time(), 0, {'state': 1})) + request = testing.DummyRequest() + request.cookies['session'] = cookieval + session = self._makeOne(request, serializer=serializer) + self.assertEqual(session['state'], 1) + +class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import UnencryptedCookieSessionFactoryConfig + self._rename_cookie_var(kw, 'path', 'cookie_path') + self._rename_cookie_var(kw, 'domain', 'cookie_domain') + self._rename_cookie_var(kw, 'secure', 'cookie_secure') + self._rename_cookie_var(kw, 'httponly', 'cookie_httponly') + self._rename_cookie_var(kw, 'set_on_exception', 'cookie_on_exception') + return UnencryptedCookieSessionFactoryConfig('secret', **kw)(request) + + def _rename_cookie_var(self, kw, src, dest): + if src in kw: + kw.setdefault(dest, kw.pop(src)) + + def _serialize(self, value): + from pyramid.session import signed_serialize + return signed_serialize(value, 'secret') + def test_serialize_option(self): from pyramid.response import Response secret = 'secret' @@ -255,54 +377,48 @@ class Test_manage_accessed(unittest.TestCase): def test_accessed_set(self): request = testing.DummyRequest() session = DummySessionFactory(request) - session.accessed = None + session.renewed = 0 wrapper = self._makeOne(session.__class__.get) wrapper(session, 'a') self.assertNotEqual(session.accessed, None) - - def test_already_dirty(self): + self.assertTrue(session._dirty) + + def test_accessed_without_renew(self): + import time request = testing.DummyRequest() session = DummySessionFactory(request) - session._dirty = True - session['a'] = 1 + session._reissue_time = 5 + session.renewed = time.time() wrapper = self._makeOne(session.__class__.get) - self.assertEqual(wrapper.__doc__, session.get.__doc__) - result = wrapper(session, 'a') - self.assertEqual(result, 1) - callbacks = request.response_callbacks - self.assertEqual(len(callbacks), 0) + wrapper(session, 'a') + self.assertNotEqual(session.accessed, None) + self.assertFalse(session._dirty) - def test_with_exception(self): - import webob + def test_already_dirty(self): request = testing.DummyRequest() - request.exception = True session = DummySessionFactory(request) + session.renewed = 0 + session._dirty = True session['a'] = 1 wrapper = self._makeOne(session.__class__.get) self.assertEqual(wrapper.__doc__, session.get.__doc__) result = wrapper(session, 'a') self.assertEqual(result, 1) callbacks = request.response_callbacks - self.assertEqual(len(callbacks), 1) - response = webob.Response() - result = callbacks[0](request, response) - self.assertEqual(result, None) - self.assertFalse('Set-Cookie' in dict(response.headerlist)) + self.assertEqual(len(callbacks), 0) - def test_cookie_is_set(self): +class Test_manage_changed(unittest.TestCase): + def _makeOne(self, wrapped): + from pyramid.session import manage_changed + return manage_changed(wrapped) + + def test_it(self): request = testing.DummyRequest() session = DummySessionFactory(request) - session['a'] = 1 - wrapper = self._makeOne(session.__class__.get) - self.assertEqual(wrapper.__doc__, session.get.__doc__) - result = wrapper(session, 'a') - self.assertEqual(result, 1) - callbacks = request.response_callbacks - self.assertEqual(len(callbacks), 1) - response = DummyResponse() - result = callbacks[0](request, response) - self.assertEqual(result, None) - self.assertEqual(session.response, response) + wrapper = self._makeOne(session.__class__.__setitem__) + wrapper(session, 'a', 1) + self.assertNotEqual(session.accessed, None) + self.assertTrue(session._dirty) def serialize(data, secret): import hmac @@ -354,7 +470,67 @@ class Test_signed_deserialize(unittest.TestCase): def test_it_bad_encoding(self): serialized = 'bad' + serialize('123', 'secret') self.assertRaises(ValueError, self._callFUT, serialized, 'secret') - + +class TestSignedSerializer(unittest.TestCase): + def _makeOne(self, secret='secret', hashalg='sha512'): + from pyramid.session import _SignedSerializer + serializer = DummySerializer() + return _SignedSerializer(secret, hashalg, serializer) + + def test_it_same_serializer(self): + serializer = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer.dumps(appstruct) + result = serializer.loads(cstruct) + self.assertEqual(result, appstruct) + + def test_it_different_serializers(self): + serializer1 = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer1.dumps(appstruct) + + serializer2 = self._makeOne() + result = serializer2.loads(cstruct) + self.assertEqual(result, appstruct) + + def test_invalid_signature_with_different_secret(self): + serializer1 = self._makeOne('secret1') + appstruct = {'state': 1} + cstruct = serializer1.dumps(appstruct) + + serializer2 = self._makeOne('secret2') + try: + serializer2.loads(cstruct) + except ValueError as exc: + self.assertTrue('Invalid signature' in exc.args[0]) + else: # pragma: no cover + self.fail() + + def test_invalid_signature_after_tamper(self): + import base64 + serializer = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer.dumps(appstruct) + actual_val = base64.b64decode(cstruct) + test_val = base64.b64encode(actual_val[1:]) + try: + serializer.loads(test_val) + except ValueError as exc: + self.assertTrue('Invalid signature' in exc.args[0]) + else: # pragma: no cover + self.fail() + + def test_invalid_data_size(self): + import base64 + serializer = self._makeOne() + num_bytes = serializer.digest_size + serializer.salt_size - 1 + try: + serializer.loads(base64.b64encode(b' ' * num_bytes)) + except ValueError as exc: + self.assertTrue('Input is too short' in exc.args[0]) + else: # pragma: no cover + self.fail() + class Test_check_csrf_token(unittest.TestCase): def _callFUT(self, *args, **kwargs): from ..session import check_csrf_token @@ -390,6 +566,13 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) +class DummySerializer(object): + def dumps(self, value): + return json.dumps(value).encode('utf-8') + + def loads(self, value): + return json.loads(value.decode('utf-8')) + class DummySessionFactory(dict): _dirty = False _cookie_name = 'session' @@ -399,13 +582,14 @@ class DummySessionFactory(dict): _cookie_secure = False _cookie_httponly = False _timeout = 1200 - _secret = 'secret' + _reissue_time = 0 + def __init__(self, request): self.request = request dict.__init__(self, {}) - def _set_cookie(self, response): - self.response = response + def changed(self): + self._dirty = True class DummyResponse(object): def __init__(self): -- cgit v1.2.3 From 61e938dbbb75849f70e7d426b717bdf03d9f3ff4 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 5 Oct 2013 03:59:07 -0500 Subject: fix py3 --- pyramid/session.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index e6635ca1b..3c493a561 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -522,9 +522,11 @@ def SignedCookieSessionFactory( ``serializer`` An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. The value generated from - serialization will be cryptographically signed to prevent tampering. - A ``ValueError`` should be raised if deserialization fails. + to perform serialization and deserialization. + - ``dumps(value)`` should accept a Python object and return a + bytestring which can later be deserialized with ``loads``. + - ``loads(value)`` should expect to receive a bytestring, generated by + ``dumps`` and return a Python object. .. versionadded: 1.5a3 """ @@ -563,7 +565,7 @@ class _SignedSerializer(object): self.serializer = serializer def derive_key(self, salt): - return hmac.new(self.secret, salt, self.digestmod).digest() + return hmac.new(bytes_(self.secret), salt, self.digestmod).digest() def dumps(self, appstruct): salt = os.urandom(self.salt_size) -- cgit v1.2.3 From 0905d2015e35e827c3fdb2135695710b80d549a5 Mon Sep 17 00:00:00 2001 From: "Karl O. Pinc" Date: Tue, 8 Oct 2013 11:50:11 -0500 Subject: Subclass HTTPBadCSRFToken from HTTPBadRequest and have request.session.check_csrf_token use the new exception. This supports a more fine-grained exception trapping. --- docs/api/httpexceptions.rst | 19 ++++++++++++++++--- pyramid/httpexceptions.py | 40 ++++++++++++++++++++++++++++++++++++---- pyramid/session.py | 6 +++--- pyramid/tests/test_session.py | 5 +++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/api/httpexceptions.rst b/docs/api/httpexceptions.rst index 6a08d1048..0fdd0f0e9 100644 --- a/docs/api/httpexceptions.rst +++ b/docs/api/httpexceptions.rst @@ -7,9 +7,12 @@ .. attribute:: status_map - A mapping of integer status code to exception class (eg. the - integer "401" maps to - :class:`pyramid.httpexceptions.HTTPUnauthorized`). + A mapping of integer status code to HTTP exception class (eg. the integer + "401" maps to :class:`pyramid.httpexceptions.HTTPUnauthorized`). All + mapped exception classes are children of :class:`pyramid.httpexceptions`, + i.e. the :ref:`pyramid_specific_http_exceptions` such as + :class:`pyramid.httpexceptions.HTTPBadRequest.BadCSRFToken` are not + mapped. .. autofunction:: exception_response @@ -106,3 +109,13 @@ .. autoclass:: HTTPVersionNotSupported .. autoclass:: HTTPInsufficientStorage + + +.. _pyramid_specific_http_exceptions: + +Pyramid-specific HTTP Exceptions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each Pyramid-specific HTTP exception has the status code of it's parent. + + .. autoclass:: HTTPBadCSRFToken diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index fff17b2df..21d862a6b 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -2,10 +2,13 @@ HTTP Exceptions --------------- -This module contains Pyramid HTTP exception classes. Each class relates to a -single HTTP status code. Each class is a subclass of the -:class:`~HTTPException`. Each exception class is also a :term:`response` -object. +This module contains Pyramid HTTP exception classes. Each class is a subclass +of the :class:`~HTTPException`. Each class relates to a single HTTP status +code, although the reverse is not true. There are +:ref:`pyramid_specific_http_exceptions` which are sub-classes of the +:rfc:`2608` HTTP status codes. Each of these Pyramid-specific exceptions have +the status code of it's parent. Each exception class is also a +:term:`response` object. Each exception class has a status code according to :rfc:`2068`: codes with 100-300 are not really errors; 400s are client errors, @@ -32,6 +35,9 @@ Exception HTTPError HTTPClientError * 400 - HTTPBadRequest + + * 400 - HTTPBadCSRFToken + * 401 - HTTPUnauthorized * 402 - HTTPPaymentRequired * 403 - HTTPForbidden @@ -565,8 +571,34 @@ class HTTPClientError(HTTPError): 'it is either malformed or otherwise incorrect.') class HTTPBadRequest(HTTPClientError): + """ + subclass of :class:`~HTTPClientError` + + base class for Pyramid-specific validity checks of the client's request + + This class and it's sub-classes result in a '400 Bad Request' HTTP status, + although it's sub-classes specialize the 'Bad Request' text. + """ pass +class HTTPBadCSRFToken(HTTPClientError): + """ + subclass of :class:`~HTTPBadRequest` + + This indicates the request has failed cross-site request forgery token + validation. + + title: Bad CSRF Token + """ + title = 'Bad CSRF Token' + explanation = ( + 'Access is denied. This server can not verify that your cross-site ' + 'request forgery token belongs to your login session. Either you ' + 'supplied the wrong cross-site request forgery token or your session ' + 'no longer exists. This may be due to session timeout or because ' + 'browser is not supplying the credentials required, as can happen ' + 'when the browser has cookies turned off.') + class HTTPUnauthorized(HTTPClientError): """ subclass of :class:`~HTTPClientError` diff --git a/pyramid/session.py b/pyramid/session.py index 3708ef879..72b69117c 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -15,7 +15,7 @@ from pyramid.compat import ( native_, ) -from pyramid.httpexceptions import HTTPBadRequest +from pyramid.httpexceptions import HTTPBadCSRFToken from pyramid.interfaces import ISession from pyramid.util import strings_differ @@ -95,7 +95,7 @@ def check_csrf_token(request, If the value supplied by param or by header doesn't match the value supplied by ``request.session.get_csrf_token()``, and ``raises`` is ``True``, this function will raise an - :exc:`pyramid.httpexceptions.HTTPBadRequest` exception. + :exc:`pyramid.httpexceptions.HTTPBadCSRFToken` exception. If the check does succeed and ``raises`` is ``False``, this function will return ``False``. If the CSRF check is successful, this function will return ``True`` unconditionally. @@ -108,7 +108,7 @@ def check_csrf_token(request, supplied_token = request.params.get(token, request.headers.get(header)) if supplied_token != request.session.get_csrf_token(): if raises: - raise HTTPBadRequest('incorrect CSRF token') + raise HTTPBadCSRFToken('check_csrf_token(): Invalid token') return False return True diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 35e2b5c27..a928af43e 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -381,9 +381,10 @@ class Test_check_csrf_token(unittest.TestCase): self.assertEqual(self._callFUT(request), True) def test_failure_raises(self): - from pyramid.httpexceptions import HTTPBadRequest + from pyramid.httpexceptions import HTTPBadCSRFToken request = testing.DummyRequest() - self.assertRaises(HTTPBadRequest, self._callFUT, request, 'csrf_token') + self.assertRaises(HTTPBadCSRFToken, self._callFUT, request, + 'csrf_token') def test_failure_no_raises(self): request = testing.DummyRequest() -- cgit v1.2.3 From cd218d2934c87260bbb10620e3b419b275fe6244 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 14 Oct 2013 16:05:50 +0200 Subject: make these tests pass on python 3.2+ --- docs/tutorials/wiki/src/tests/tutorial/tests.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/wiki/src/tests/tutorial/tests.py b/docs/tutorials/wiki/src/tests/tutorial/tests.py index c435a4519..5add04c20 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/tests.py +++ b/docs/tutorials/wiki/src/tests/tutorial/tests.py @@ -158,11 +158,11 @@ class FunctionalTests(unittest.TestCase): def test_FrontPage(self): res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('FrontPage' in res.body) + self.assertTrue(b'FrontPage' in res.body) def test_unexisting_page(self): res = self.testapp.get('/SomePage', status=404) - self.assertTrue('Not Found' in res.body) + self.assertTrue(b'Not Found' in res.body) def test_successful_log_in(self): res = self.testapp.get( self.viewer_login, status=302) @@ -170,48 +170,48 @@ class FunctionalTests(unittest.TestCase): def test_failed_log_in(self): res = self.testapp.get( self.viewer_wrong_login, status=200) - self.assertTrue('login' in res.body) + self.assertTrue(b'login' in res.body) def test_logout_link_present_when_logged_in(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('Logout' in res.body) + self.assertTrue(b'Logout' in res.body) def test_logout_link_not_present_after_logged_out(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) res = self.testapp.get('/logout', status=302) - self.assertTrue('Logout' not in res.body) + self.assertTrue(b'Logout' not in res.body) def test_anonymous_user_cannot_edit(self): res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_anonymous_user_cannot_add(self): res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_viewer_user_cannot_edit(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_viewer_user_cannot_add(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_editors_member_user_can_edit(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Editing' in res.body) + self.assertTrue(b'Editing' in res.body) def test_editors_member_user_can_add(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Editing' in res.body) + self.assertTrue(b'Editing' in res.body) def test_editors_member_user_can_view(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('FrontPage' in res.body) + self.assertTrue(b'FrontPage' in res.body) -- cgit v1.2.3 From f23f38db37e8e323512424e5715a40dc2dce9ab8 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 14 Oct 2013 16:08:02 +0200 Subject: Revert "make these tests pass on python 3.2+" This reverts commit cd218d2934c87260bbb10620e3b419b275fe6244. --- docs/tutorials/wiki/src/tests/tutorial/tests.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/wiki/src/tests/tutorial/tests.py b/docs/tutorials/wiki/src/tests/tutorial/tests.py index 5add04c20..c435a4519 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/tests.py +++ b/docs/tutorials/wiki/src/tests/tutorial/tests.py @@ -158,11 +158,11 @@ class FunctionalTests(unittest.TestCase): def test_FrontPage(self): res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) + self.assertTrue('FrontPage' in res.body) def test_unexisting_page(self): res = self.testapp.get('/SomePage', status=404) - self.assertTrue(b'Not Found' in res.body) + self.assertTrue('Not Found' in res.body) def test_successful_log_in(self): res = self.testapp.get( self.viewer_login, status=302) @@ -170,48 +170,48 @@ class FunctionalTests(unittest.TestCase): def test_failed_log_in(self): res = self.testapp.get( self.viewer_wrong_login, status=200) - self.assertTrue(b'login' in res.body) + self.assertTrue('login' in res.body) def test_logout_link_present_when_logged_in(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'Logout' in res.body) + self.assertTrue('Logout' in res.body) def test_logout_link_not_present_after_logged_out(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) res = self.testapp.get('/logout', status=302) - self.assertTrue(b'Logout' not in res.body) + self.assertTrue('Logout' not in res.body) def test_anonymous_user_cannot_edit(self): res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_anonymous_user_cannot_add(self): res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_viewer_user_cannot_edit(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_viewer_user_cannot_add(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_editors_member_user_can_edit(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) + self.assertTrue('Editing' in res.body) def test_editors_member_user_can_add(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) + self.assertTrue('Editing' in res.body) def test_editors_member_user_can_view(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) + self.assertTrue('FrontPage' in res.body) -- cgit v1.2.3 From b0b09c9bfb4924ce22627f4da94d3216829d5ec8 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:00:47 -0500 Subject: update session to use a static salt and separate serialize funcs --- pyramid/session.py | 140 +++++++++++++++++++------------------- pyramid/tests/test_session.py | 154 +++++++++++++++++++++--------------------- 2 files changed, 145 insertions(+), 149 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 3c493a561..800400223 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -120,7 +120,8 @@ def check_csrf_token(request, return True def BaseCookieSessionFactory( - serializer, + serialize, + deserialize, cookie_name='session', max_age=None, path='/', @@ -151,13 +152,13 @@ def BaseCookieSessionFactory( Parameters: - ``serializer`` - An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. - - ``dumps(value)`` should accept a Python object and return a - bytestring which can later be deserialized with ``loads``. - - ``loads(value)`` should expect to receive a bytestring, generated by - ``dumps`` and return a Python object. + ``serialize`` + A callable accepting a Python object and returning a bytestring. A + ``ValueError`` should be raised for malformed inputs. + + ``deserialize`` + A callable accepting a bytestring and returning a Python object. A + ``ValueError`` should be raised for malformed inputs. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. @@ -235,8 +236,9 @@ def BaseCookieSessionFactory( cookieval = request.cookies.get(self._cookie_name) if cookieval is not None: try: - value = serializer.loads(bytes_(cookieval)) + value = deserialize(bytes_(cookieval)) except ValueError: + # the cookie failed to deserialize, dropped value = None if value is not None: @@ -244,8 +246,12 @@ def BaseCookieSessionFactory( renewed, created, state = value new = False if now - renewed > self._timeout: + # expire the session because it was not renewed + # before the timeout threshold state = {} except TypeError: + # value failed to unpack properly or renewed was not + # a numeric type so we'll fail deserialization here state = {} self.created = created @@ -328,7 +334,7 @@ def BaseCookieSessionFactory( exception = getattr(self.request, 'exception', None) if exception is not None: # dont set a cookie during exceptions return False - cookieval = native_(serializer.dumps( + cookieval = native_(serialize( (self.accessed, self.created, dict(self)) )) if len(cookieval) > 4064: @@ -418,15 +424,9 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ - class _Serializer(object): - def dumps(self, appstruct): - return signed_serialize(appstruct, secret) - - def loads(self, bstruct): - return signed_deserialize(bstruct, secret) - return BaseCookieSessionFactory( - _Serializer(), + lambda v: signed_serialize(v, secret), + lambda v: signed_deserialize(v, secret), cookie_name=cookie_name, max_age=cookie_max_age, path=cookie_path, @@ -450,7 +450,9 @@ def SignedCookieSessionFactory( timeout=1200, reissue_time=0, hashalg='sha512', - serializer=None, + salt='pyramid.session.', + serialize=None, + deserialize=None, ): """ Configure a :term:`session factory` which will provide signed @@ -469,12 +471,19 @@ def SignedCookieSessionFactory( Parameters: ``secret`` - A string which is used to sign the cookie. + A string which is used to sign the cookie. The secret should be at + least as long as the block size of the selected hash algorithm. For + ``sha512`` this would mean a 128 bit (64 character) secret. ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. + ``salt`` + A namespace to avoid collisions between different uses of a shared + secret. Reusing a secret for different parts of an application is + strongly discouraged. + ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. @@ -520,77 +529,64 @@ def SignedCookieSessionFactory( If ``True``, set a session cookie even if an exception occurs while rendering a view. Default: ``True``. - ``serializer`` - An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. - - ``dumps(value)`` should accept a Python object and return a - bytestring which can later be deserialized with ``loads``. - - ``loads(value)`` should expect to receive a bytestring, generated by - ``dumps`` and return a Python object. + ``serialize`` + A callable accepting a Python object and returning a bytestring. A + ``ValueError`` should be raised for malformed inputs. + Default: :func:`pickle.dumps`. + + ``deserialize`` + A callable accepting a bytestring and returning a Python object. A + ``ValueError`` should be raised for malformed inputs. + Default: :func:`pickle.loads`. .. versionadded: 1.5a3 """ - if serializer is None: - serializer = _PickleSerializer() - - signed_serializer = _SignedSerializer(secret, hashalg, serializer) - - return BaseCookieSessionFactory( - signed_serializer, - cookie_name=cookie_name, - max_age=max_age, - path=path, - domain=domain, - secure=secure, - httponly=httponly, - timeout=timeout, - reissue_time=reissue_time, - set_on_exception=set_on_exception, - ) - -class _PickleSerializer(object): - def dumps(self, appstruct): - return pickle.dumps(appstruct, pickle.HIGHEST_PROTOCOL) + if serialize is None: + serialize = lambda v: pickle.dumps(v, pickle.HIGHEST_PROTOCOL) - def loads(self, bstruct): - return pickle.loads(bstruct) + if deserialize is None: + deserialize = pickle.loads -class _SignedSerializer(object): - def __init__(self, secret, hashalg, serializer): - self.secret = secret - self.digestmod = lambda: hashlib.new(hashalg) - self.digest_size = self.digestmod().digest_size - self.salt_size = 8 - self.serializer = serializer + digestmod = lambda: hashlib.new(hashalg) + digest_size = digestmod().digest_size - def derive_key(self, salt): - return hmac.new(bytes_(self.secret), salt, self.digestmod).digest() + salted_secret = bytes_(salt or '') + bytes_(secret) - def dumps(self, appstruct): - salt = os.urandom(self.salt_size) - derived_secret = self.derive_key(salt) - cstruct = self.serializer.dumps(appstruct) - sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() - return base64.b64encode(cstruct + salt + sig) + def signed_serialize(appstruct): + cstruct = serialize(appstruct) + sig = hmac.new(salted_secret, cstruct, digestmod).digest() + return base64.b64encode(cstruct + sig) - def loads(self, bstruct): + def signed_deserialize(bstruct): try: fstruct = base64.b64decode(bstruct) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) - cstruct_size = len(fstruct) - self.salt_size - self.digest_size + cstruct_size = len(fstruct) - digest_size if cstruct_size < 0: raise ValueError('Input is too short.') cstruct = fstruct[:cstruct_size] - salt = fstruct[cstruct_size:cstruct_size + self.salt_size] - expected_sig = fstruct[-self.digest_size:] + expected_sig = fstruct[-digest_size:] - derived_secret = self.derive_key(salt) - sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + sig = hmac.new(salted_secret, cstruct, digestmod).digest() if strings_differ(sig, expected_sig): raise ValueError('Invalid signature') - return self.serializer.loads(cstruct) + return deserialize(cstruct) + + return BaseCookieSessionFactory( + signed_serialize, + signed_deserialize, + cookie_name=cookie_name, + max_age=max_age, + path=path, + domain=domain, + secure=secure, + httponly=httponly, + timeout=timeout, + reissue_time=reissue_time, + set_on_exception=set_on_exception, + ) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 0e1ed78a6..04740b0cd 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -264,7 +264,8 @@ class SharedCookieSessionTests(object): class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import BaseCookieSessionFactory - return BaseCookieSessionFactory(DummySerializer(), **kw)(request) + return BaseCookieSessionFactory( + dummy_serialize, dummy_deserialize, **kw)(request) def _serialize(self, value): return json.dumps(value) @@ -281,13 +282,19 @@ class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import SignedCookieSessionFactory - return SignedCookieSessionFactory('secret', **kw)(request) + kw.setdefault('secret', 'secret') + return SignedCookieSessionFactory(**kw)(request) - def _serialize(self, value): - from pyramid.session import _SignedSerializer, _PickleSerializer - serializer = _PickleSerializer() - serializer = _SignedSerializer('secret', 'sha512', serializer) - return serializer.dumps(value) + def _serialize(self, value, salt='pyramid.session.', hashalg='sha512'): + import base64 + import hashlib + import hmac + import pickle + + digestmod = lambda: hashlib.new(hashalg) + cstruct = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + sig = hmac.new(salt + 'secret', cstruct, digestmod).digest() + return base64.b64encode(cstruct + sig) def test_reissue_not_triggered(self): import time @@ -298,17 +305,71 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): self.assertEqual(session['state'], 1) self.assertFalse(session._dirty) + def test_custom_salt(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + request.cookies['session'] = cookieval + session = self._makeOne(request, salt='f.') + self.assertEqual(session['state'], 1) + + def test_salt_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + request.cookies['session'] = cookieval + session = self._makeOne(request, salt='g.') + self.assertEqual(session, {}) + + def test_custom_hashalg(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), + hashalg='sha1') + request.cookies['session'] = cookieval + session = self._makeOne(request, hashalg='sha1') + self.assertEqual(session['state'], 1) + + def test_hashalg_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), + hashalg='sha1') + request.cookies['session'] = cookieval + session = self._makeOne(request, hashalg='sha256') + self.assertEqual(session, {}) + + def test_secret_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, secret='evilsecret') + self.assertEqual(session, {}) + def test_custom_serializer(self): + import base64 + from hashlib import sha512 + import hmac import time - from pyramid.session import _SignedSerializer - serializer = DummySerializer() - signer = _SignedSerializer('secret', 'sha512', serializer=serializer) - cookieval = signer.dumps((time.time(), 0, {'state': 1})) request = testing.DummyRequest() + cstruct = dummy_serialize((time.time(), 0, {'state': 1})) + sig = hmac.new('pyramid.session.secret', cstruct, sha512).digest() + cookieval = base64.b64encode(cstruct + sig) request.cookies['session'] = cookieval - session = self._makeOne(request, serializer=serializer) + session = self._makeOne(request, deserialize=dummy_deserialize) self.assertEqual(session['state'], 1) + def test_invalid_data_size(self): + from hashlib import sha512 + import base64 + request = testing.DummyRequest() + num_bytes = sha512().digest_size - 1 + cookieval = base64.b64encode(b' ' * num_bytes) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(session, {}) + class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import UnencryptedCookieSessionFactoryConfig @@ -471,66 +532,6 @@ class Test_signed_deserialize(unittest.TestCase): serialized = 'bad' + serialize('123', 'secret') self.assertRaises(ValueError, self._callFUT, serialized, 'secret') -class TestSignedSerializer(unittest.TestCase): - def _makeOne(self, secret='secret', hashalg='sha512'): - from pyramid.session import _SignedSerializer - serializer = DummySerializer() - return _SignedSerializer(secret, hashalg, serializer) - - def test_it_same_serializer(self): - serializer = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer.dumps(appstruct) - result = serializer.loads(cstruct) - self.assertEqual(result, appstruct) - - def test_it_different_serializers(self): - serializer1 = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer1.dumps(appstruct) - - serializer2 = self._makeOne() - result = serializer2.loads(cstruct) - self.assertEqual(result, appstruct) - - def test_invalid_signature_with_different_secret(self): - serializer1 = self._makeOne('secret1') - appstruct = {'state': 1} - cstruct = serializer1.dumps(appstruct) - - serializer2 = self._makeOne('secret2') - try: - serializer2.loads(cstruct) - except ValueError as exc: - self.assertTrue('Invalid signature' in exc.args[0]) - else: # pragma: no cover - self.fail() - - def test_invalid_signature_after_tamper(self): - import base64 - serializer = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer.dumps(appstruct) - actual_val = base64.b64decode(cstruct) - test_val = base64.b64encode(actual_val[1:]) - try: - serializer.loads(test_val) - except ValueError as exc: - self.assertTrue('Invalid signature' in exc.args[0]) - else: # pragma: no cover - self.fail() - - def test_invalid_data_size(self): - import base64 - serializer = self._makeOne() - num_bytes = serializer.digest_size + serializer.salt_size - 1 - try: - serializer.loads(base64.b64encode(b' ' * num_bytes)) - except ValueError as exc: - self.assertTrue('Input is too short' in exc.args[0]) - else: # pragma: no cover - self.fail() - class Test_check_csrf_token(unittest.TestCase): def _callFUT(self, *args, **kwargs): from ..session import check_csrf_token @@ -566,12 +567,11 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) -class DummySerializer(object): - def dumps(self, value): - return json.dumps(value).encode('utf-8') +def dummy_serialize(value): + return json.dumps(value).encode('utf-8') - def loads(self, value): - return json.loads(value.decode('utf-8')) +def dummy_deserialize(value): + return json.loads(value.decode('utf-8')) class DummySessionFactory(dict): _dirty = False -- cgit v1.2.3 From 10c6857185e299b4c6932c2a378ad3adb14867d8 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:12:20 -0500 Subject: add deprecation for old cookie factory --- CHANGES.txt | 6 ++++++ pyramid/session.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 550dd0a39..feea11def 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -15,6 +15,12 @@ Bug Fixes allowing traversal to continue. See https://github.com/Pylons/pyramid/issues/1104 +Deprecations +------------ + +- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and will + be replaced by the ``SignedCookieSessionFactory``. + 1.5a2 (2013-09-22) ================== diff --git a/pyramid/session.py b/pyramid/session.py index 800400223..803d56066 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -1,10 +1,10 @@ -import hashlib -from hashlib import sha1 import base64 import binascii +import hashlib import hmac -import time import os +import time +import warnings from zope.interface import implementer @@ -55,7 +55,7 @@ def signed_serialize(data, secret): response.set_cookie('signed_cookie', cookieval) """ pickled = pickle.dumps(data, pickle.HIGHEST_PROTOCOL) - sig = hmac.new(bytes_(secret), pickled, sha1).hexdigest() + sig = hmac.new(bytes_(secret), pickled, hashlib.sha1).hexdigest() return sig + native_(base64.b64encode(pickled)) def signed_deserialize(serialized, secret, hmac=hmac): @@ -79,7 +79,7 @@ def signed_deserialize(serialized, secret, hmac=hmac): # Badly formed data can make base64 die raise ValueError('Badly formed base64 data: %s' % e) - sig = bytes_(hmac.new(bytes_(secret), pickled, sha1).hexdigest()) + sig = bytes_(hmac.new(bytes_(secret), pickled, hashlib.sha1).hexdigest()) # Avoid timing attacks (see # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) @@ -424,6 +424,14 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ + warnings.warn( + ('The UnencryptedCookieSessionFactoryConfig is deprecated as of ' + 'Pyramid 1.5, and will be replaced by the ' + 'SignedCookieSessionFactory in future versions.'), + DeprecationWarning, + stacklevel=2 + ) + return BaseCookieSessionFactory( lambda v: signed_serialize(v, secret), lambda v: signed_deserialize(v, secret), -- cgit v1.2.3 From 554a020f91553d60efb449d0d23ea3e37ecdb42d Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:15:48 -0500 Subject: fix tests on python 3 --- pyramid/tests/test_session.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 04740b0cd..382cf8eb5 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -285,7 +285,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): kw.setdefault('secret', 'secret') return SignedCookieSessionFactory(**kw)(request) - def _serialize(self, value, salt='pyramid.session.', hashalg='sha512'): + def _serialize(self, value, salt=b'pyramid.session.', hashalg='sha512'): import base64 import hashlib import hmac @@ -293,7 +293,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): digestmod = lambda: hashlib.new(hashalg) cstruct = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) - sig = hmac.new(salt + 'secret', cstruct, digestmod).digest() + sig = hmac.new(salt + b'secret', cstruct, digestmod).digest() return base64.b64encode(cstruct + sig) def test_reissue_not_triggered(self): @@ -308,17 +308,17 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): def test_custom_salt(self): import time request = testing.DummyRequest() - cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt=b'f.') request.cookies['session'] = cookieval - session = self._makeOne(request, salt='f.') + session = self._makeOne(request, salt=b'f.') self.assertEqual(session['state'], 1) def test_salt_mismatch(self): import time request = testing.DummyRequest() - cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt=b'f.') request.cookies['session'] = cookieval - session = self._makeOne(request, salt='g.') + session = self._makeOne(request, salt=b'g.') self.assertEqual(session, {}) def test_custom_hashalg(self): @@ -354,7 +354,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): import time request = testing.DummyRequest() cstruct = dummy_serialize((time.time(), 0, {'state': 1})) - sig = hmac.new('pyramid.session.secret', cstruct, sha512).digest() + sig = hmac.new(b'pyramid.session.secret', cstruct, sha512).digest() cookieval = base64.b64encode(cstruct + sig) request.cookies['session'] = cookieval session = self._makeOne(request, deserialize=dummy_deserialize) -- cgit v1.2.3 From 63bf0587066216f9879ab188691579c9565f0340 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:22:35 -0500 Subject: updated changelog --- CHANGES.txt | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index feea11def..a9b9814f3 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -6,6 +6,21 @@ Documentation - Added a "Quick Tutorial" to go with the Quick Tour +Features +-------- + +- Added a new ``SignedCookieSessionFactory`` which is very similar to the + ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on + signing content. The custom serializer arguments to this function should + only focus on serializing, unlike its predecessor which required the + serializer to also perform signing. + +- Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie + factory that can be used by framework implementors to create their own + session implementations. It provides a reusable API which focuses strictly + on providing a dictionary-like object that properly handles renewals, + timeouts, and conformance with the ``ISession`` API. + Bug Fixes --------- @@ -18,9 +33,9 @@ Bug Fixes Deprecations ------------ -- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and will - be replaced by the ``SignedCookieSessionFactory``. - +- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is + superceded by the ``SignedCookieSessionFactory``. Cookies generated by + the two factories are not compatible. 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 8df7a71d99bbeb7819e8a2752012d51202669aa6 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:30:58 -0500 Subject: update the docs --- docs/api/session.rst | 8 ++++++-- docs/narr/sessions.rst | 19 +++++++++---------- docs/quick_tour/package/hello_world/__init__.py | 4 ++-- docs/quick_tour/package/hello_world/init.py | 4 ++-- docs/quick_tutorial/sessions/tutorial/__init__.py | 6 +++--- pyramid/session.py | 2 +- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/api/session.rst b/docs/api/session.rst index 31bc196ad..dde9d20e9 100644 --- a/docs/api/session.rst +++ b/docs/api/session.rst @@ -5,12 +5,16 @@ .. automodule:: pyramid.session - .. autofunction:: UnencryptedCookieSessionFactoryConfig - .. autofunction:: signed_serialize .. autofunction:: signed_deserialize .. autofunction:: check_csrf_token + .. autofunction:: SignedCookieSessionFactory + + .. autofunction:: UnencryptedCookieSessionFactoryConfig + + .. autofunction:: BaseCookieSessionFactory + diff --git a/docs/narr/sessions.rst b/docs/narr/sessions.rst index 358977089..1d914f9ea 100644 --- a/docs/narr/sessions.rst +++ b/docs/narr/sessions.rst @@ -43,24 +43,23 @@ limitations: It is digitally signed, however, and thus its data cannot easily be tampered with. -You can configure this session factory in your :app:`Pyramid` -application by using the ``session_factory`` argument to the -:class:`~pyramid.config.Configurator` class: +You can configure this session factory in your :app:`Pyramid` application +by using the :meth:`pyramid.config.Configurator.set_session_factory`` method. .. code-block:: python :linenos: - from pyramid.session import UnencryptedCookieSessionFactoryConfig - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') - + from pyramid.session import SignedCookieSessionFactory + my_session_factory = SignedCookieSessionFactory('itsaseekreet') + from pyramid.config import Configurator - config = Configurator(session_factory = my_session_factory) + config = Configurator() + config.set_session_factory(my_session_factory) .. warning:: - Note the very long, very explicit name for - ``UnencryptedCookieSessionFactoryConfig``. It's trying to tell you that - this implementation is, by default, *unencrypted*. You should not use it + By default the :func:`~pyramid.session.SignedCookieSessionFactory` + implementation is *unencrypted*. You should not use it when you keep sensitive information in the session object, as the information can be easily read by both users of your application and third parties who have access to your users' network traffic. And if you use this diff --git a/docs/quick_tour/package/hello_world/__init__.py b/docs/quick_tour/package/hello_world/__init__.py index 6e66bf40a..4a4fbec30 100644 --- a/docs/quick_tour/package/hello_world/__init__.py +++ b/docs/quick_tour/package/hello_world/__init__.py @@ -1,7 +1,7 @@ from pyramid.config import Configurator from pyramid_jinja2 import renderer_factory # Start Sphinx Include 1 -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory # End Sphinx Include 1 from hello_world.models import get_root @@ -16,7 +16,7 @@ def main(global_config, **settings): settings.setdefault('jinja2.i18n.domain', 'hello_world') # Start Sphinx Include 2 - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') + my_session_factory = SignedCookieSessionFactory('itsaseekreet') config = Configurator(root_factory=get_root, settings=settings, session_factory=my_session_factory) # End Sphinx Include 2 diff --git a/docs/quick_tour/package/hello_world/init.py b/docs/quick_tour/package/hello_world/init.py index 9d7ec43d8..5b5f6a118 100644 --- a/docs/quick_tour/package/hello_world/init.py +++ b/docs/quick_tour/package/hello_world/init.py @@ -1,7 +1,7 @@ from pyramid.config import Configurator from pyramid_jinja2 import renderer_factory # Start Sphinx 1 -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory # End Sphinx 1 from hello_world.models import get_root @@ -22,7 +22,7 @@ def main(global_config, **settings): # End Include # Start Sphinx Include 2 - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') + my_session_factory = SignedCookieSessionFactory('itsaseekreet') config = Configurator(session_factory=my_session_factory) # End Sphinx Include 2 diff --git a/docs/quick_tutorial/sessions/tutorial/__init__.py b/docs/quick_tutorial/sessions/tutorial/__init__.py index ecf57bb32..9ddc2e1b1 100644 --- a/docs/quick_tutorial/sessions/tutorial/__init__.py +++ b/docs/quick_tutorial/sessions/tutorial/__init__.py @@ -1,9 +1,9 @@ from pyramid.config import Configurator -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory def main(global_config, **settings): - my_session_factory = UnencryptedCookieSessionFactoryConfig( + my_session_factory = SignedCookieSessionFactory( 'itsaseekreet') config = Configurator(settings=settings, session_factory=my_session_factory) @@ -11,4 +11,4 @@ def main(global_config, **settings): config.add_route('home', '/') config.add_route('hello', '/howdy') config.scan('.views') - return config.make_wsgi_app() \ No newline at end of file + return config.make_wsgi_app() diff --git a/pyramid/session.py b/pyramid/session.py index 803d56066..6ffef7a22 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -490,7 +490,7 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged. + strongly discouraged. Default: ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 0e2914bc0d5f6f4cab1cfe11e3c6e88dd96ecbb6 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:43:17 -0500 Subject: move HTTPBadCSRFToken to p.exceptions.BadCSRFToken --- pyramid/exceptions.py | 15 +++++++++++++++ pyramid/httpexceptions.py | 21 --------------------- pyramid/session.py | 6 +++--- pyramid/tests/test_exceptions.py | 6 ++++++ pyramid/tests/test_session.py | 4 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyramid/exceptions.py b/pyramid/exceptions.py index a8fca1d84..c59d109df 100644 --- a/pyramid/exceptions.py +++ b/pyramid/exceptions.py @@ -1,4 +1,5 @@ from pyramid.httpexceptions import ( + HTTPBadRequest, HTTPNotFound, HTTPForbidden, ) @@ -8,6 +9,20 @@ Forbidden = HTTPForbidden # bw compat CR = '\n' +class BadCSRFToken(HTTPBadRequest): + """ + This exception indicates the request has failed cross-site request + forgery token validation. + """ + title = 'Bad CSRF Token' + explanation = ( + 'Access is denied. This server can not verify that your cross-site ' + 'request forgery token belongs to your login session. Either you ' + 'supplied the wrong cross-site request forgery token or your session ' + 'no longer exists. This may be due to session timeout or because ' + 'browser is not supplying the credentials required, as can happen ' + 'when the browser has cookies turned off.') + class PredicateMismatch(HTTPNotFound): """ This exception is raised by multiviews when no view matches diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index 21d862a6b..5e8d8ccd8 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -35,9 +35,6 @@ Exception HTTPError HTTPClientError * 400 - HTTPBadRequest - - * 400 - HTTPBadCSRFToken - * 401 - HTTPUnauthorized * 402 - HTTPPaymentRequired * 403 - HTTPForbidden @@ -581,24 +578,6 @@ class HTTPBadRequest(HTTPClientError): """ pass -class HTTPBadCSRFToken(HTTPClientError): - """ - subclass of :class:`~HTTPBadRequest` - - This indicates the request has failed cross-site request forgery token - validation. - - title: Bad CSRF Token - """ - title = 'Bad CSRF Token' - explanation = ( - 'Access is denied. This server can not verify that your cross-site ' - 'request forgery token belongs to your login session. Either you ' - 'supplied the wrong cross-site request forgery token or your session ' - 'no longer exists. This may be due to session timeout or because ' - 'browser is not supplying the credentials required, as can happen ' - 'when the browser has cookies turned off.') - class HTTPUnauthorized(HTTPClientError): """ subclass of :class:`~HTTPClientError` diff --git a/pyramid/session.py b/pyramid/session.py index 72b69117c..d3318cbda 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -15,7 +15,7 @@ from pyramid.compat import ( native_, ) -from pyramid.httpexceptions import HTTPBadCSRFToken +from pyramid.exceptions import BadCSRFToken from pyramid.interfaces import ISession from pyramid.util import strings_differ @@ -95,7 +95,7 @@ def check_csrf_token(request, If the value supplied by param or by header doesn't match the value supplied by ``request.session.get_csrf_token()``, and ``raises`` is ``True``, this function will raise an - :exc:`pyramid.httpexceptions.HTTPBadCSRFToken` exception. + :exc:`pyramid.exceptions.BadCSRFToken` exception. If the check does succeed and ``raises`` is ``False``, this function will return ``False``. If the CSRF check is successful, this function will return ``True`` unconditionally. @@ -108,7 +108,7 @@ def check_csrf_token(request, supplied_token = request.params.get(token, request.headers.get(header)) if supplied_token != request.session.get_csrf_token(): if raises: - raise HTTPBadCSRFToken('check_csrf_token(): Invalid token') + raise BadCSRFToken('check_csrf_token(): Invalid token') return False return True diff --git a/pyramid/tests/test_exceptions.py b/pyramid/tests/test_exceptions.py index aa5ebb376..993209046 100644 --- a/pyramid/tests/test_exceptions.py +++ b/pyramid/tests/test_exceptions.py @@ -11,6 +11,12 @@ class TestBWCompat(unittest.TestCase): from pyramid.httpexceptions import HTTPForbidden as two self.assertTrue(one is two) +class TestBadCSRFToken(unittest.TestCase): + def test_response_equivalence(self): + from pyramid.exceptions import BadCSRFToken + from pyramid.httpexceptions import HTTPBadRequest + self.assertTrue(isinstance(BadCSRFToken(), HTTPBadRequest)) + class TestNotFound(unittest.TestCase): def _makeOne(self, message): from pyramid.exceptions import NotFound diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index a928af43e..9337ab8eb 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -381,9 +381,9 @@ class Test_check_csrf_token(unittest.TestCase): self.assertEqual(self._callFUT(request), True) def test_failure_raises(self): - from pyramid.httpexceptions import HTTPBadCSRFToken + from pyramid.exceptions import BadCSRFToken request = testing.DummyRequest() - self.assertRaises(HTTPBadCSRFToken, self._callFUT, request, + self.assertRaises(BadCSRFToken, self._callFUT, request, 'csrf_token') def test_failure_no_raises(self): -- cgit v1.2.3 From 6b0889cc8f3711d5f77cb663f8f2fa432eb3ad06 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:52:11 -0500 Subject: update doc references --- CHANGES.txt | 5 +++++ docs/api/exceptions.rst | 2 ++ docs/api/httpexceptions.rst | 13 ------------- pyramid/httpexceptions.py | 17 +++++++---------- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index a228fbb3a..fcfb83e4f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -10,6 +10,11 @@ Features python -3 -m pyramid.scripts.pserve development.ini +- Added a specific subclass of ``HTTPBadRequest`` named + ``pyramid.exceptions.BadCSRFToken`` which will now be raised in response + to failures in ``check_csrf_token``. + See https://github.com/Pylons/pyramid/pull/1149 + Bug Fixes --------- diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst index ab158f18d..0c630571f 100644 --- a/docs/api/exceptions.rst +++ b/docs/api/exceptions.rst @@ -5,6 +5,8 @@ .. automodule:: pyramid.exceptions + .. autoclass:: BadCSRFToken + .. autoclass:: PredicateMismatch .. autoclass:: Forbidden diff --git a/docs/api/httpexceptions.rst b/docs/api/httpexceptions.rst index 0fdd0f0e9..b50f10beb 100644 --- a/docs/api/httpexceptions.rst +++ b/docs/api/httpexceptions.rst @@ -10,9 +10,6 @@ A mapping of integer status code to HTTP exception class (eg. the integer "401" maps to :class:`pyramid.httpexceptions.HTTPUnauthorized`). All mapped exception classes are children of :class:`pyramid.httpexceptions`, - i.e. the :ref:`pyramid_specific_http_exceptions` such as - :class:`pyramid.httpexceptions.HTTPBadRequest.BadCSRFToken` are not - mapped. .. autofunction:: exception_response @@ -109,13 +106,3 @@ .. autoclass:: HTTPVersionNotSupported .. autoclass:: HTTPInsufficientStorage - - -.. _pyramid_specific_http_exceptions: - -Pyramid-specific HTTP Exceptions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Each Pyramid-specific HTTP exception has the status code of it's parent. - - .. autoclass:: HTTPBadCSRFToken diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index 5e8d8ccd8..ebee39ada 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -2,13 +2,10 @@ HTTP Exceptions --------------- -This module contains Pyramid HTTP exception classes. Each class is a subclass -of the :class:`~HTTPException`. Each class relates to a single HTTP status -code, although the reverse is not true. There are -:ref:`pyramid_specific_http_exceptions` which are sub-classes of the -:rfc:`2608` HTTP status codes. Each of these Pyramid-specific exceptions have -the status code of it's parent. Each exception class is also a -:term:`response` object. +This module contains Pyramid HTTP exception classes. Each class relates to a +single HTTP status code. Each class is a subclass of the +:class:`~HTTPException`. Each exception class is also a :term:`response` +object. Each exception class has a status code according to :rfc:`2068`: codes with 100-300 are not really errors; 400s are client errors, @@ -571,10 +568,10 @@ class HTTPBadRequest(HTTPClientError): """ subclass of :class:`~HTTPClientError` - base class for Pyramid-specific validity checks of the client's request + This indicates that the body or headers failed validity checks, + preventing the server from being able to continue processing. - This class and it's sub-classes result in a '400 Bad Request' HTTP status, - although it's sub-classes specialize the 'Bad Request' text. + code: 400, title: Bad Request """ pass -- cgit v1.2.3 From 8385569b371a2586acf1680937ca656136c2502c Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 02:02:19 -0500 Subject: reference github issues --- CHANGES.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index a9b9814f3..f67291ca5 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -14,12 +14,14 @@ Features signing content. The custom serializer arguments to this function should only focus on serializing, unlike its predecessor which required the serializer to also perform signing. + See https://github.com/Pylons/pyramid/pull/1142 - Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie factory that can be used by framework implementors to create their own session implementations. It provides a reusable API which focuses strictly on providing a dictionary-like object that properly handles renewals, timeouts, and conformance with the ``ISession`` API. + See https://github.com/Pylons/pyramid/pull/1142 Bug Fixes --------- @@ -36,6 +38,7 @@ Deprecations - The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is superceded by the ``SignedCookieSessionFactory``. Cookies generated by the two factories are not compatible. + See https://github.com/Pylons/pyramid/pull/1142 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 604297a083419278d85be47e40d1905043c38460 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:10:54 -0500 Subject: attempt to decode basic header as utf-8 and fallback to latin-1 fixes #898 fixes #904 --- pyramid/authentication.py | 9 ++++++++- pyramid/tests/test_authentication.py | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pyramid/authentication.py b/pyramid/authentication.py index 454ebd4b2..6b6fbd041 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -1176,10 +1176,17 @@ class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): return None if authmeth.lower() != 'basic': return None + try: - auth = b64decode(auth.strip()).decode('ascii') + authbytes = b64decode(auth.strip()) except (TypeError, binascii.Error): # can't decode return None + + try: + auth = authbytes.decode('utf-8') + except UnicodeDecodeError: + auth = authbytes.decode('latin-1') + try: username, password = auth.split(':', 1) except ValueError: # not enough values to unpack diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index 19e95cf9a..ed6cc5903 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1378,11 +1378,25 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): import base64 request = testing.DummyRequest() inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') - request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) + request.headers['Authorization'] = 'Basic %s' % ( + base64.b64encode(inputs.encode('utf-8'))) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + self.assertEqual(policy.authenticated_userid(request), + b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + + def test_authenticated_userid_latin1(self): + import base64 + request = testing.DummyRequest() + inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + request.headers['Authorization'] = 'Basic %s' % ( + base64.b64encode(inputs.encode('latin-1'))) + def check(username, password, request): + return [] + policy = self._makeOne(check) + self.assertEqual(policy.authenticated_userid(request), + b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From 42f0cb2923200f07c89e011f80fe15e3c65caf03 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:18:05 -0500 Subject: update changelog --- CHANGES.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index a228fbb3a..f170308b0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -32,6 +32,12 @@ Bug Fixes - Remove unused ``renderer`` argument from ``Configurator.add_route``. +- Allow the ``BasicAuthenticationPolicy`` to work with non-ascii usernames + and passwords. The charset is not passed as part of the header and different + browsers alternate between UTF-8 and Latin-1, so the policy now attempts + to decode with UTF-8 first, and will fallback to Latin-1. + See https://github.com/Pylons/pyramid/pull/1170 + Documentation ------------- -- cgit v1.2.3 From 6c98b17ed9aadbe485c6473c3f76e1b2b529dc78 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:26:53 -0500 Subject: fix tests on py3 --- pyramid/tests/test_authentication.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index ed6cc5903..3ac8f2d61 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1377,9 +1377,10 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = (b'm\xc3\xb6rk\xc3\xb6:' + b'm\xc3\xb6rk\xc3\xb6password').decode('utf-8') request.headers['Authorization'] = 'Basic %s' % ( - base64.b64encode(inputs.encode('utf-8'))) + base64.b64encode(inputs.encode('utf-8')).decode('latin-1')) def check(username, password, request): return [] policy = self._makeOne(check) @@ -1389,9 +1390,10 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_latin1(self): import base64 request = testing.DummyRequest() - inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = (b'm\xc3\xb6rk\xc3\xb6:' + b'm\xc3\xb6rk\xc3\xb6password').decode('utf-8') request.headers['Authorization'] = 'Basic %s' % ( - base64.b64encode(inputs.encode('latin-1'))) + base64.b64encode(inputs.encode('latin-1')).decode('latin-1')) def check(username, password, request): return [] policy = self._makeOne(check) -- cgit v1.2.3 From 9536f9b9c470ea03de4bfa98b1f0c3583bb8f394 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 15:22:38 -0400 Subject: use zope.deprecation for warning about the UnencryptedCookieSessionFactoryConfig deprecation (it will happen at import time, rather than usage time, which is good for tests); add a few sphinx directives for deprecated and versionadded --- pyramid/session.py | 32 +++++++++++++++++++------------- pyramid/tests/test_session.py | 10 ++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 6ffef7a22..60a5f7a63 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -4,8 +4,8 @@ import hashlib import hmac import os import time -import warnings +from zope.deprecation import deprecated from zope.interface import implementer from pyramid.compat import ( @@ -133,11 +133,13 @@ def BaseCookieSessionFactory( set_on_exception=True, ): """ + .. versionadded:: 1.5 + Configure a :term:`session factory` which will provide cookie-based - sessions. The return value of this function is a - :term:`session factory`, which may be provided as the ``session_factory`` - argument of a :class:`pyramid.config.Configurator` constructor, or used - as the ``session_factory`` argument of the + sessions. The return value of this function is a :term:`session factory`, + which may be provided as the ``session_factory`` argument of a + :class:`pyramid.config.Configurator` constructor, or used as the + ``session_factory`` argument of the :meth:`pyramid.config.Configurator.set_session_factory` method. The session factory returned by this function will create sessions @@ -355,6 +357,7 @@ def BaseCookieSessionFactory( return CookieSession + def UnencryptedCookieSessionFactoryConfig( secret, timeout=1200, @@ -369,6 +372,9 @@ def UnencryptedCookieSessionFactoryConfig( signed_deserialize=signed_deserialize, ): """ + .. deprecated:: 1.5 + Use :func:`pyramid.session.SignedCookieSessionFactory` instead. + Configure a :term:`session factory` which will provide unencrypted (but signed) cookie-based sessions. The return value of this function is a :term:`session factory`, which may be provided as @@ -424,14 +430,6 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ - warnings.warn( - ('The UnencryptedCookieSessionFactoryConfig is deprecated as of ' - 'Pyramid 1.5, and will be replaced by the ' - 'SignedCookieSessionFactory in future versions.'), - DeprecationWarning, - stacklevel=2 - ) - return BaseCookieSessionFactory( lambda v: signed_serialize(v, secret), lambda v: signed_deserialize(v, secret), @@ -446,6 +444,12 @@ def UnencryptedCookieSessionFactoryConfig( set_on_exception=cookie_on_exception, ) +deprecated( + 'UnencryptedCookieSessionFactoryConfig', + 'The UnencryptedCookieSessionFactoryConfig callable is deprecated as of ' + 'Pyramid 1.5. Use ``pyramid.session.SignedCookieSessionFactory`` instead.' + ) + def SignedCookieSessionFactory( secret, cookie_name='session', @@ -463,6 +467,8 @@ def SignedCookieSessionFactory( deserialize=None, ): """ + .. versionadded:: 1.5 + Configure a :term:`session factory` which will provide signed cookie-based sessions. The return value of this function is a :term:`session factory`, which may be provided as diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 382cf8eb5..eba123ce5 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -371,6 +371,16 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): self.assertEqual(session, {}) class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def setUp(self): + super(TestUnencryptedCookieSession, self).setUp() + from zope.deprecation import __show__ + __show__.off() + + def tearDown(self): + super(TestUnencryptedCookieSession, self).tearDown() + from zope.deprecation import __show__ + __show__.on() + def _makeOne(self, request, **kw): from pyramid.session import UnencryptedCookieSessionFactoryConfig self._rename_cookie_var(kw, 'path', 'cookie_path') -- cgit v1.2.3 From e521f14cc4d986c2ad400abff3d6cb7ff784b775 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 15:57:33 -0400 Subject: add admonishment against secret sharing --- docs/narr/security.rst | 28 ++++++++++++++++++++++++++++ pyramid/authentication.py | 4 +++- pyramid/session.py | 8 ++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 6517fedf8..9884bb1dc 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -669,3 +669,31 @@ following interface: After you do so, you can pass an instance of such a class into the :class:`~pyramid.config.Configurator.set_authorization_policy` method at configuration time to use it. + +.. _admonishment_against_secret_sharing: + +Admomishment Against Secret-Sharing +----------------------------------- + +A "secret" is required by various components of Pyramid. For example, the +:term:`authentication policy` below uses a secret value ``seekrit``:: + + authn_policy = AuthTktAuthenticationPolicy('seekrit', hashalg='sha512') + +A :term:`session factory` also requires a secret:: + + my_session_factory = SignedCookieSessionFactory('itsaseekreet') + +It is tempting to use the same secret for multiple Pyramid subsystems. For +example, you might be tempted to use the value ``seekrit`` as the secret for +both the authentication policy and the session factory defined above. This is +a bad idea, because in both cases, these secrets are used to sign the payload +of the data. + +If you use the same secret for two different parts of your application for +signing purposes, it may allow an attacker to get his chosen plaintext signed, +which would allow the attacker to control the content of the payload. Re-using +a secret across two different subsystems might drop the security of signing to +zero. Keys should not be re-used across different contexts where an attacker +has the possibility of providing a chosen plaintext. + diff --git a/pyramid/authentication.py b/pyramid/authentication.py index 454ebd4b2..3c4077073 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -424,7 +424,9 @@ class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): ``secret`` - The secret (a string) used for auth_tkt cookie signing. + The secret (a string) used for auth_tkt cookie signing. This value + should be unique across all values provided to Pyramid for various + subsystem secrets (see :ref:`admonishment_against_secret_sharing`). Required. ``callback`` diff --git a/pyramid/session.py b/pyramid/session.py index 60a5f7a63..f14783adb 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -487,7 +487,9 @@ def SignedCookieSessionFactory( ``secret`` A string which is used to sign the cookie. The secret should be at least as long as the block size of the selected hash algorithm. For - ``sha512`` this would mean a 128 bit (64 character) secret. + ``sha512`` this would mean a 128 bit (64 character) secret. It should + be unique within the set of secret values provided to Pyramid for + its various subsystems (see :ref:`admonishment_against_secret_sharing`). ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be @@ -496,7 +498,9 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged. Default: ``'pyramid.session.'``. + strongly discouraged, see (see + :ref:`admonishment_against_secret_sharing`). Default: + ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 94360dffe85332733f35f2fb3ab32de3fedd787e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 16:00:40 -0400 Subject: mon --- docs/narr/security.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 9884bb1dc..e85ed823a 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -672,7 +672,7 @@ configuration time to use it. .. _admonishment_against_secret_sharing: -Admomishment Against Secret-Sharing +Admonishment Against Secret-Sharing ----------------------------------- A "secret" is required by various components of Pyramid. For example, the -- cgit v1.2.3 From 7c756b9ace5c858e78d0ba6baccb5af2bd17a2df Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:10:26 -0500 Subject: remove redundant "see" --- pyramid/session.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index f14783adb..7224bef1a 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -498,9 +498,8 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged, see (see - :ref:`admonishment_against_secret_sharing`). Default: - ``'pyramid.session.'``. + strongly discouraged, (see :ref:`admonishment_against_secret_sharing`). + Default: ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 2dea188aefc75837fabe76ae53e6a79d3e16f946 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:10:55 -0500 Subject: moar typos --- pyramid/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/session.py b/pyramid/session.py index 7224bef1a..9ff9ffa20 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -498,7 +498,7 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged, (see :ref:`admonishment_against_secret_sharing`). + strongly discouraged (see :ref:`admonishment_against_secret_sharing`). Default: ``'pyramid.session.'``. ``cookie_name`` -- cgit v1.2.3 From dc491c48cd313c7d92e141ea91d8904f635c71b5 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:20:21 -0500 Subject: remove unnecessary length check, slices are magic --- pyramid/session.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 9ff9ffa20..2471d94ad 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -581,11 +581,7 @@ def SignedCookieSessionFactory( except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) - cstruct_size = len(fstruct) - digest_size - if cstruct_size < 0: - raise ValueError('Input is too short.') - - cstruct = fstruct[:cstruct_size] + cstruct = fstruct[:-digest_size] expected_sig = fstruct[-digest_size:] sig = hmac.new(salted_secret, cstruct, digestmod).digest() -- cgit v1.2.3 From d79087c78c273eec3118a23243b9b93d353b09f2 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sun, 20 Oct 2013 13:54:16 -0400 Subject: rewording about deprecation and cookie compatibility --- CHANGES.txt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 80bd78808..6fdc08398 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -44,6 +44,8 @@ Bug Fixes allowing traversal to continue. See https://github.com/Pylons/pyramid/issues/1104 +- Remove unused ``renderer`` argument from ``Configurator.add_route``. + Documentation ------------- @@ -65,11 +67,13 @@ Backwards Incompatibilities Deprecations ------------ -- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is - superceded by the ``SignedCookieSessionFactory``. Cookies generated by - the two factories are not compatible. - See https://github.com/Pylons/pyramid/pull/1142 -- Remove unused ``renderer`` argument from ``Configurator.add_route``. +- The ``pyramid.session.UnencryptedCookieSessionFactoryConfig`` API has been + deprecated and is superseded by the + ``pyramid.session.SignedCookieSessionFactory``. Note that while the cookies + generated by the ``UnencryptedCookieSessionFactoryConfig`` + are compatible with cookies generated by old releases, cookies generated by + the SignedCookieSessionFactory are not. See + https://github.com/Pylons/pyramid/pull/1142 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 2edbe1b61c7ace0a13f0d7242f333982a6fc9fde Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sun, 20 Oct 2013 16:20:23 -0400 Subject: add a note so we can defend the choice later --- pyramid/authentication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyramid/authentication.py b/pyramid/authentication.py index ec8ac0a41..2c301bd29 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -1184,6 +1184,8 @@ class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): except (TypeError, binascii.Error): # can't decode return None + # try utf-8 first, then latin-1; see discussion in + # https://github.com/Pylons/pyramid/issues/898 try: auth = authbytes.decode('utf-8') except UnicodeDecodeError: -- cgit v1.2.3