diff options
| author | Michael Merickel <michael@merickel.org> | 2017-04-29 01:43:38 -0500 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2017-04-29 02:04:58 -0500 |
| commit | 682a9b9df6f42f8261daa077f04b47b65bf00c34 (patch) | |
| tree | 54232513a60d4c94b21221280e9138f7a2219485 | |
| parent | 4b3603ad2f4850605c45e1b7bf4f077584303641 (diff) | |
| download | pyramid-682a9b9df6f42f8261daa077f04b47b65bf00c34.tar.gz pyramid-682a9b9df6f42f8261daa077f04b47b65bf00c34.tar.bz2 pyramid-682a9b9df6f42f8261daa077f04b47b65bf00c34.zip | |
final cleanup of csrf decoupling in #2854
- Renamed `SessionCSRFStoragePolicy` to `LegacySessionCSRFStoragePolicy` for
the version that uses the legacy `ISession.get_csrf_token` and
`ISession.new_csrf_token` apis and set that as the default.
- Added new `SessionCSRFStoragePolicy` that stores data in the session
similar to how the `SessionAuthenticationPolicy` works.
- `CookieCSRFStoragePolicy` did not properly return the newly generated
token from `get_csrf_token` after calling `new_csrf_token`. It needed
to cache the new value since the response callback does not affect
the current request.
- `CookieCSRFStoragePolicy` was not forwarding the `domain` value to the
`CookieProfile` causing that setting to be ignored.
- Removed `check_csrf_token` from the `ICSRFStoragePolicy` interface
to simplify implementations of storage policies.
- Added an introspectable item for the configured storage policy so that
it appears on the debugtoolbar.
- Added a change note on `ISession` that it no longer required the csrf methods.
- Leave deprecated shims in ``pyramid.session`` for
``check_csrf_origin`` and ``check_csrf_token``.
| -rw-r--r-- | CHANGES.txt | 13 | ||||
| -rw-r--r-- | docs/api/csrf.rst | 3 | ||||
| -rw-r--r-- | docs/narr/security.rst | 1 | ||||
| -rw-r--r-- | docs/narr/templates.rst | 4 | ||||
| -rw-r--r-- | pyramid/config/security.py | 20 | ||||
| -rw-r--r-- | pyramid/csrf.py | 109 | ||||
| -rw-r--r-- | pyramid/interfaces.py | 30 | ||||
| -rw-r--r-- | pyramid/session.py | 14 | ||||
| -rw-r--r-- | pyramid/tests/test_csrf.py | 108 | ||||
| -rw-r--r-- | pyramid/tests/test_util.py | 6 |
10 files changed, 190 insertions, 118 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index 762550053..7d70abbb8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -24,12 +24,13 @@ Features can be alleviated by invoking ``config.begin()`` and ``config.end()`` appropriately. See https://github.com/Pylons/pyramid/pull/2989 -- A new CSRF implementation, :class:`pyramid.csrf.SessionCSRF` has been added, - which delegates all CSRF generation to the current session, following the - old API for this. A ``get_csrf_token()`` method is now available in template - global scope, to make it easy for template developers to get the current CSRF - token without adding it to Python code. - See https://github.com/Pylons/pyramid/pull/2854 +- A new CSRF implementation, ``pyramid.csrf.SessionCSRFStoragePolicy``, + has been added which delegates all CSRF generation to the current session, + following the old API for this. A ``pyramid.csrf.get_csrf_token()`` api is now + available in template global scope, to make it easy for template developers + to get the current CSRF token without adding it to Python code. + See https://github.com/Pylons/pyramid/pull/2854 and + https://github.com/Pylons/pyramid/pull/3019 Bug Fixes diff --git a/docs/api/csrf.rst b/docs/api/csrf.rst index f890ee660..38501546e 100644 --- a/docs/api/csrf.rst +++ b/docs/api/csrf.rst @@ -5,6 +5,9 @@ .. automodule:: pyramid.csrf + .. autoclass:: LegacySessionCSRFStoragePolicy + :members: + .. autoclass:: SessionCSRFStoragePolicy :members: diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 86e5c1ef4..ddf496b69 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -824,6 +824,7 @@ If no CSRF token previously existed for this user, then a new token will be set into the session and returned. The newly created token will be opaque and randomized. +.. _get_csrf_token_in_templates: Using the ``get_csrf_token`` global in templates ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/narr/templates.rst b/docs/narr/templates.rst index 6b3b5fcce..4eadbd2f0 100644 --- a/docs/narr/templates.rst +++ b/docs/narr/templates.rst @@ -228,6 +228,10 @@ These values are provided to the template: provided if the template is rendered as the result of a ``renderer=`` argument to the view configuration being used. +``get_csrf_token()`` + A convenience function to access the current CSRF token. See + :ref:`get_csrf_token_in_templates` for more information. + ``renderer_name`` The renderer name used to perform the rendering, e.g., ``mypackage:templates/foo.pt``. diff --git a/pyramid/config/security.py b/pyramid/config/security.py index 9d59ca78e..8e4c908d3 100644 --- a/pyramid/config/security.py +++ b/pyramid/config/security.py @@ -10,7 +10,7 @@ from pyramid.interfaces import ( PHASE2_CONFIG, ) -from pyramid.csrf import SessionCSRFStoragePolicy +from pyramid.csrf import LegacySessionCSRFStoragePolicy from pyramid.exceptions import ConfigurationError from pyramid.util import action_method from pyramid.util import as_sorted_tuple @@ -19,7 +19,7 @@ from pyramid.util import as_sorted_tuple class SecurityConfiguratorMixin(object): def add_default_security(self): - self.set_csrf_storage_policy(SessionCSRFStoragePolicy()) + self.set_csrf_storage_policy(LegacySessionCSRFStoragePolicy()) @action_method def set_authentication_policy(self, policy): @@ -232,16 +232,22 @@ class SecurityConfiguratorMixin(object): @action_method def set_csrf_storage_policy(self, policy): """ - Set the :term:`CSRF storage policy` used by subsequent view registrations. + Set the :term:`CSRF storage policy` used by subsequent view + registrations. ``policy`` is a class that implements the - :meth:`pyramid.interfaces.ICSRFStoragePolicy` interface that will be used for all - CSRF functionality. + :meth:`pyramid.interfaces.ICSRFStoragePolicy` interface and defines + how to generate and persist CSRF tokens. + """ def register(): self.registry.registerUtility(policy, ICSRFStoragePolicy) - - self.action(ICSRFStoragePolicy, register) + intr = self.introspectable('csrf storage policy', + None, + policy, + 'csrf storage policy') + intr['policy'] = policy + self.action(ICSRFStoragePolicy, register, introspectables=(intr,)) @implementer(IDefaultCSRFOptions) diff --git a/pyramid/csrf.py b/pyramid/csrf.py index 5d183bb57..1910e4ec8 100644 --- a/pyramid/csrf.py +++ b/pyramid/csrf.py @@ -7,8 +7,9 @@ from zope.interface import implementer from pyramid.authentication import _SimpleSerializer from pyramid.compat import ( + bytes_, urlparse, - bytes_ + text_, ) from pyramid.exceptions import ( BadCSRFOrigin, @@ -23,44 +24,79 @@ from pyramid.util import ( @implementer(ICSRFStoragePolicy) -class SessionCSRFStoragePolicy(object): - """ The default CSRF implementation, which mimics the behavior from older - versions of Pyramid. The ``new_csrf_token`` and ``get_csrf_token`` methods - are indirected to the underlying session implementation. +class LegacySessionCSRFStoragePolicy(object): + """ A CSRF storage policy that defers control of CSRF storage to the + session. + + This policy maintains compatibility with legacy ISession implementations + that know how to manage CSRF tokens themselves via + ``ISession.new_csrf_token`` and ``ISession.get_csrf_token``. Note that using this CSRF implementation requires that a :term:`session factory` is configured. - .. versionadded :: 1.9 + .. versionadded:: 1.9 + """ def new_csrf_token(self, request): """ Sets a new CSRF token into the session and returns it. """ return request.session.new_csrf_token() def get_csrf_token(self, request): - """ Returns the currently active CSRF token from the session, generating - a new one if needed.""" + """ Returns the currently active CSRF token from the session, + generating a new one if needed.""" return request.session.get_csrf_token() - def check_csrf_token(self, request, supplied_token): - """ Returns ``True`` if ``supplied_token is`` the same value as - ``get_csrf_token(request)``.""" - expected = self.get_csrf_token(request) - return not strings_differ( - bytes_(expected, 'ascii'), - bytes_(supplied_token, 'ascii'), - ) + +@implementer(ICSRFStoragePolicy) +class SessionCSRFStoragePolicy(object): + """ A CSRF storage policy that persists the CSRF token in the session. + + Note that using this CSRF implementation requires that + a :term:`session factory` is configured. + + ``key`` + + The session key where the CSRF token will be stored. + Default: `_csrft_`. + + .. versionadded:: 1.9 + + """ + _token_factory = staticmethod(lambda: text_(uuid.uuid4().hex)) + + def __init__(self, key='_csrft_'): + self.key = key + + def new_csrf_token(self, request): + """ Sets a new CSRF token into the session and returns it. """ + token = self._token_factory() + request.session[self.key] = token + return token + + def get_csrf_token(self, request): + """ Returns the currently active CSRF token from the session, + generating a new one if needed.""" + token = request.session.get(self.key, None) + if not token: + token = self.new_csrf_token(request) + return token + @implementer(ICSRFStoragePolicy) class CookieCSRFStoragePolicy(object): """ An alternative CSRF implementation that stores its information in unauthenticated cookies, known as the 'Double Submit Cookie' method in the - `OWASP CSRF guidelines <https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Double_Submit_Cookie>`_. - This gives some additional flexibility with regards to scaling as the tokens - can be generated and verified by a front-end server. + `OWASP CSRF guidelines <https://www.owasp.org/index.php/ + Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet# + Double_Submit_Cookie>`_. This gives some additional flexibility with + regards to scaling as the tokens can be generated and verified by a + front-end server. + + .. versionadded:: 1.9 - .. versionadded :: 1.9 """ + _token_factory = staticmethod(lambda: text_(uuid.uuid4().hex)) def __init__(self, cookie_name='csrf_token', secure=False, httponly=False, domain=None, max_age=None, path='/'): @@ -71,13 +107,15 @@ class CookieCSRFStoragePolicy(object): max_age=max_age, httponly=httponly, path=path, + domains=[domain], serializer=serializer ) - self.domain = domain + self.cookie_name = cookie_name def new_csrf_token(self, request): """ Sets a new CSRF token into the request and returns it. """ - token = uuid.uuid4().hex + token = self._token_factory() + request.cookies[self.cookie_name] = token def set_cookie(request, response): self.cookie_profile.set_cookies( response, @@ -95,14 +133,6 @@ class CookieCSRFStoragePolicy(object): token = self.new_csrf_token(request) return token - def check_csrf_token(self, request, supplied_token): - """ Returns ``True`` if ``supplied_token is`` the same value as - ``get_csrf_token(request)``.""" - expected = self.get_csrf_token(request) - return not strings_differ( - bytes_(expected, 'ascii'), - bytes_(supplied_token, 'ascii'), - ) def get_csrf_token(request): """ Get the currently active CSRF token for the request passed, generating @@ -133,8 +163,8 @@ def check_csrf_token(request, header='X-CSRF-Token', raises=True): """ Check the CSRF token returned by the - :class:`pyramid.interfaces.ICSRFStoragePolicy` implementation against the value in - ``request.POST.get(token)`` (if a POST request) or + :class:`pyramid.interfaces.ICSRFStoragePolicy` implementation against the + value in ``request.POST.get(token)`` (if a POST request) or ``request.headers.get(header)``. If a ``token`` keyword is not supplied to this function, the string ``csrf_token`` will be used to look up the token in ``request.POST``. If a ``header`` keyword is not supplied to this @@ -143,11 +173,12 @@ def check_csrf_token(request, If the value supplied by post or by header doesn't match the value supplied by ``policy.get_csrf_token()`` (where ``policy`` is an implementation of - :class:`pyramid.interfaces.ICSRFStoragePolicy`), and ``raises`` is ``True``, this - function will raise an :exc:`pyramid.exceptions.BadCSRFToken` exception. If - the values differ and ``raises`` is ``False``, this function will return - ``False``. If the CSRF check is successful, this function will return - ``True`` unconditionally. + :class:`pyramid.interfaces.ICSRFStoragePolicy`), and ``raises`` is + ``True``, this function will raise an + :exc:`pyramid.exceptions.BadCSRFToken` exception. If the values differ + and ``raises`` is ``False``, this function will return ``False``. If the + CSRF check is successful, this function will return ``True`` + unconditionally. See :ref:`auto_csrf_checking` for information about how to secure your application automatically against CSRF attacks. @@ -176,8 +207,8 @@ def check_csrf_token(request, if supplied_token == "" and token is not None: supplied_token = request.POST.get(token, "") - policy = request.registry.queryUtility(ICSRFStoragePolicy) - if not policy.check_csrf_token(request, supplied_token): + expected_token = get_csrf_token(request) + if strings_differ(bytes_(expected_token), bytes_(supplied_token)): if raises: raise BadCSRFToken('check_csrf_token(): Invalid token') return False diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index c3b6b164d..853e8fcdd 100644 --- a/pyramid/interfaces.py +++ b/pyramid/interfaces.py @@ -927,6 +927,13 @@ class ISession(IDict): usually accessed via ``request.session``. Keys and values of a session must be pickleable. + + .. versionchanged:: 1.9 + + Sessions are no longer required to implement ``get_csrf_token`` and + ``new_csrf_token``. CSRF token support was moved to the pluggable + :class:`pyramid.interfaces.ICSRFStoragePolicy` configuration hook. + """ # attributes @@ -984,24 +991,23 @@ class ISession(IDict): class ICSRFStoragePolicy(Interface): """ An object that offers the ability to verify CSRF tokens and generate - new ones""" + new ones.""" def new_csrf_token(request): - """ Create and return a new, random cross-site request forgery - protection token. Return the token. It will be a string.""" + """ Create and return a new, random cross-site request forgery + protection token. The token will be an ascii-compatible unicode + string. + + """ def get_csrf_token(request): """ Return a cross-site request forgery protection token. It - will be a string. If a token was previously set for this user via - ``new_csrf_token``, that token will be returned. If no CSRF token - was previously set, ``new_csrf_token`` will be called, which will - create and set a token, and this token will be returned. - """ + will be an ascii-compatible unicode string. If a token was previously + set for this user via ``new_csrf_token``, that token will be returned. + If no CSRF token was previously set, ``new_csrf_token`` will be + called, which will create and set a token, and this token will be + returned. - def check_csrf_token(request, supplied_token): - """ Returns a boolean that represents if ``supplied_token`` is a valid - CSRF token for this request. Comparing strings for equality must be done - using :func:`pyramid.utils.strings_differ` to avoid timing attacks. """ diff --git a/pyramid/session.py b/pyramid/session.py index b1ad25410..33119343b 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -17,6 +17,10 @@ from pyramid.compat import ( bytes_, native_, ) +from pyramid.csrf import ( + check_csrf_origin, + check_csrf_token, +) from pyramid.interfaces import ISession from pyramid.util import strings_differ @@ -608,3 +612,13 @@ def SignedCookieSessionFactory( reissue_time=reissue_time, set_on_exception=set_on_exception, ) + +check_csrf_origin = check_csrf_origin # api +deprecated('check_csrf_origin', + 'pyramid.session.check_csrf_origin is deprecated as of Pyramid ' + '1.9. Use pyramid.csrf.check_csrf_origin instead.') + +check_csrf_token = check_csrf_token # api +deprecated('check_csrf_token', + 'pyramid.session.check_csrf_token is deprecated as of Pyramid ' + '1.9. Use pyramid.csrf.check_csrf_token instead.') diff --git a/pyramid/tests/test_csrf.py b/pyramid/tests/test_csrf.py index fcb6333ee..cd7ba2951 100644 --- a/pyramid/tests/test_csrf.py +++ b/pyramid/tests/test_csrf.py @@ -49,7 +49,7 @@ class Test_new_csrf_token(unittest.TestCase): self.assertEquals(csrf_token, 'e5e9e30a08b34ff9842ff7d2b958c14b') -class TestSessionCSRFStoragePolicy(unittest.TestCase): +class TestLegacySessionCSRFStoragePolicy(unittest.TestCase): class MockSession(object): def new_csrf_token(self): return 'e5e9e30a08b34ff9842ff7d2b958c14b' @@ -58,11 +58,11 @@ class TestSessionCSRFStoragePolicy(unittest.TestCase): return '02821185e4c94269bdc38e6eeae0a2f8' def _makeOne(self): - from pyramid.csrf import SessionCSRFStoragePolicy - return SessionCSRFStoragePolicy() + from pyramid.csrf import LegacySessionCSRFStoragePolicy + return LegacySessionCSRFStoragePolicy() def test_register_session_csrf_policy(self): - from pyramid.csrf import SessionCSRFStoragePolicy + from pyramid.csrf import LegacySessionCSRFStoragePolicy from pyramid.interfaces import ICSRFStoragePolicy config = Configurator() @@ -71,7 +71,7 @@ class TestSessionCSRFStoragePolicy(unittest.TestCase): policy = config.registry.queryUtility(ICSRFStoragePolicy) - self.assertTrue(isinstance(policy, SessionCSRFStoragePolicy)) + self.assertTrue(isinstance(policy, LegacySessionCSRFStoragePolicy)) def test_session_csrf_implementation_delegates_to_session(self): policy = self._makeOne() @@ -86,26 +86,46 @@ class TestSessionCSRFStoragePolicy(unittest.TestCase): 'e5e9e30a08b34ff9842ff7d2b958c14b' ) - def test_verifying_token_invalid(self): + +class TestSessionCSRFStoragePolicy(unittest.TestCase): + def _makeOne(self, **kw): + from pyramid.csrf import SessionCSRFStoragePolicy + return SessionCSRFStoragePolicy(**kw) + + def test_register_session_csrf_policy(self): + from pyramid.csrf import SessionCSRFStoragePolicy + from pyramid.interfaces import ICSRFStoragePolicy + + config = Configurator() + config.set_csrf_storage_policy(self._makeOne()) + config.commit() + + policy = config.registry.queryUtility(ICSRFStoragePolicy) + + self.assertTrue(isinstance(policy, SessionCSRFStoragePolicy)) + + def test_it_creates_a_new_token(self): + request = DummyRequest(session={}) + policy = self._makeOne() - request = DummyRequest(session=self.MockSession()) + policy._token_factory = lambda: 'foo' + self.assertEqual(policy.get_csrf_token(request), 'foo') - result = policy.check_csrf_token(request, 'invalid-token') - self.assertFalse(result) + def test_get_csrf_token_returns_the_new_token(self): + request = DummyRequest(session={'_csrft_': 'foo'}) - def test_verifying_token_valid(self): policy = self._makeOne() - request = DummyRequest(session=self.MockSession()) + self.assertEqual(policy.get_csrf_token(request), 'foo') - result = policy.check_csrf_token( - request, '02821185e4c94269bdc38e6eeae0a2f8') - self.assertTrue(result) + token = policy.new_csrf_token(request) + self.assertNotEqual(token, 'foo') + self.assertEqual(token, policy.get_csrf_token(request)) class TestCookieCSRFStoragePolicy(unittest.TestCase): - def _makeOne(self): + def _makeOne(self, **kw): from pyramid.csrf import CookieCSRFStoragePolicy - return CookieCSRFStoragePolicy() + return CookieCSRFStoragePolicy(**kw) def test_register_cookie_csrf_policy(self): from pyramid.csrf import CookieCSRFStoragePolicy @@ -121,18 +141,18 @@ class TestCookieCSRFStoragePolicy(unittest.TestCase): def test_get_cookie_csrf_with_no_existing_cookie_sets_cookies(self): response = MockResponse() - request = DummyRequest(response=response) + request = DummyRequest() policy = self._makeOne() token = policy.get_csrf_token(request) + request.response_callback(request, response) self.assertEqual( response.headerlist, [('Set-Cookie', 'csrf_token={}; Path=/'.format(token))] ) def test_existing_cookie_csrf_does_not_set_cookie(self): - response = MockResponse() - request = DummyRequest(response=response) + request = DummyRequest() request.cookies = {'csrf_token': 'e6f325fee5974f3da4315a8ccf4513d2'} policy = self._makeOne() @@ -142,42 +162,32 @@ class TestCookieCSRFStoragePolicy(unittest.TestCase): token, 'e6f325fee5974f3da4315a8ccf4513d2' ) - self.assertEqual( - response.headerlist, - [], - ) + self.assertIsNone(request.response_callback) def test_new_cookie_csrf_with_existing_cookie_sets_cookies(self): - response = MockResponse() - request = DummyRequest(response=response) + request = DummyRequest() request.cookies = {'csrf_token': 'e6f325fee5974f3da4315a8ccf4513d2'} policy = self._makeOne() token = policy.new_csrf_token(request) + + response = MockResponse() + request.response_callback(request, response) self.assertEqual( response.headerlist, [('Set-Cookie', 'csrf_token={}; Path=/'.format(token))] ) - def test_verifying_token_invalid_token(self): - response = MockResponse() - request = DummyRequest(response=response) - request.cookies = {'csrf_token': 'e6f325fee5974f3da4315a8ccf4513d2'} + def test_get_csrf_token_returns_the_new_token(self): + request = DummyRequest() + request.cookies = {'csrf_token': 'foo'} policy = self._makeOne() - self.assertFalse( - policy.check_csrf_token(request, 'invalid-token') - ) - - def test_verifying_token_against_existing_cookie(self): - response = MockResponse() - request = DummyRequest(response=response) - request.cookies = {'csrf_token': 'e6f325fee5974f3da4315a8ccf4513d2'} + self.assertEqual(policy.get_csrf_token(request), 'foo') - policy = self._makeOne() - self.assertTrue( - policy.check_csrf_token(request, 'e6f325fee5974f3da4315a8ccf4513d2') - ) + token = policy.new_csrf_token(request) + self.assertNotEqual(token, 'foo') + self.assertEqual(token, policy.get_csrf_token(request)) class Test_check_csrf_token(unittest.TestCase): @@ -224,14 +234,6 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) - def test_token_differing_types(self): - from pyramid.compat import text_ - request = testing.DummyRequest() - request.method = "POST" - request.session['_csrft_'] = text_('foo') - request.POST = {'csrf_token': b'foo'} - self.assertEqual(self._callFUT(request, token='csrf_token'), True) - class Test_check_csrf_token_without_defaults_configured(unittest.TestCase): def setUp(self): @@ -353,15 +355,15 @@ class Test_check_csrf_origin(unittest.TestCase): class DummyRequest(object): registry = None session = None - cookies = {} + response_callback = None - def __init__(self, registry=None, session=None, response=None): + def __init__(self, registry=None, session=None): self.registry = registry self.session = session - self.response = response + self.cookies = {} def add_response_callback(self, callback): - callback(self, self.response) + self.response_callback = callback class MockResponse(object): diff --git a/pyramid/tests/test_util.py b/pyramid/tests/test_util.py index bbf6103f4..d64f0a73f 100644 --- a/pyramid/tests/test_util.py +++ b/pyramid/tests/test_util.py @@ -369,12 +369,16 @@ class Test_strings_differ(unittest.TestCase): from pyramid.util import strings_differ return strings_differ(*args, **kw) - def test_it(self): + def test_it_bytes(self): self.assertFalse(self._callFUT(b'foo', b'foo')) self.assertTrue(self._callFUT(b'123', b'345')) self.assertTrue(self._callFUT(b'1234', b'123')) self.assertTrue(self._callFUT(b'123', b'1234')) + def test_it_native_str(self): + self.assertFalse(self._callFUT('123', '123')) + self.assertTrue(self._callFUT('123', '1234')) + def test_it_with_internal_comparator(self): result = self._callFUT(b'foo', b'foo', compare_digest=None) self.assertFalse(result) |
