diff options
| -rw-r--r-- | CHANGES.txt | 38 | ||||
| -rw-r--r-- | docs/api/exceptions.rst | 2 | ||||
| -rw-r--r-- | docs/api/session.rst | 2 | ||||
| -rw-r--r-- | docs/narr/sessions.rst | 24 | ||||
| -rw-r--r-- | docs/narr/viewconfig.rst | 14 | ||||
| -rw-r--r-- | docs/whatsnew-1.7.rst | 48 | ||||
| -rw-r--r-- | pyramid/config/settings.py | 4 | ||||
| -rw-r--r-- | pyramid/exceptions.py | 15 | ||||
| -rw-r--r-- | pyramid/session.py | 136 | ||||
| -rw-r--r-- | pyramid/tests/test_config/test_views.py | 10 | ||||
| -rw-r--r-- | pyramid/tests/test_session.py | 102 | ||||
| -rw-r--r-- | pyramid/tests/test_util.py | 21 | ||||
| -rw-r--r-- | pyramid/tests/test_viewderivers.py | 76 | ||||
| -rw-r--r-- | pyramid/util.py | 17 | ||||
| -rw-r--r-- | pyramid/viewderivers.py | 12 |
15 files changed, 465 insertions, 56 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index bc4b1aa79..d316594bc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -15,6 +15,10 @@ Backward Incompatibilities See https://github.com/Pylons/pyramid/pull/2496 +- The ``check_csrf_token`` function no longer validates a csrf token in the + query string of a request. Only headers and request bodies are supported. + See https://github.com/Pylons/pyramid/pull/2500 + Features -------- @@ -25,19 +29,37 @@ Features ``require_csrf=False`` on those views. See https://github.com/Pylons/pyramid/pull/2413 -- Added a ``require_csrf`` view option which will enforce CSRF checks on POST - requests. If the CSRF check fails a ``BadCSRFToken`` exception will be - raised and may be caught by exception views (the default response is a - ``400 Bad Request``). This option should be used in place of the deprecated - ``check_csrf`` view predicate which would normally result in unexpected - ``404 Not Found`` response to the client instead of a catchable exception. - See https://github.com/Pylons/pyramid/pull/2413 +- Added a ``require_csrf`` view option which will enforce CSRF checks on any + request with an unsafe method as defined by RFC2616. If the CSRF check fails + a ``BadCSRFToken`` exception will be raised and may be caught by exception + views (the default response is a ``400 Bad Request``). This option should be + used in place of the deprecated ``check_csrf`` view predicate which would + normally result in unexpected ``404 Not Found`` response to the client + instead of a catchable exception. See + https://github.com/Pylons/pyramid/pull/2413 and + https://github.com/Pylons/pyramid/pull/2500 + +- Added an additional CSRF validation that checks the origin/referrer of a + request and makes sure it matches the current ``request.domain``. This + particular check is only active when accessing a site over HTTPS as otherwise + browsers don't always send the required information. If this additional CSRF + validation fails a ``BadCSRFOrigin`` exception will be raised and may be + caught by exception views (the default response is ``400 Bad Request``). + Additional allowed origins may be configured by setting + ``pyramid.csrf_trusted_origins`` to a list of domain names (with ports if on + a non standard port) to allow. Subdomains are not allowed unless the domain + name has been prefixed with a ``.``. See + https://github.com/Pylons/pyramid/pull/2501 + +- Added a new ``pyramid.session.check_csrf_origin`` API for validating the + origin or referrer headers against the request's domain. + See https://github.com/Pylons/pyramid/pull/2501 - Pyramid HTTPExceptions will now take into account the best match for the clients Accept header, and depending on what is requested will return text/html, application/json or text/plain. The default for */* is still text/html, but if application/json is explicitly mentioned it will now - receive a valid JSON response. See: + receive a valid JSON response. See https://github.com/Pylons/pyramid/pull/2489 - A new event and interface (BeforeTraversal) has been introduced that will diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst index faca0fbb6..cb411458d 100644 --- a/docs/api/exceptions.rst +++ b/docs/api/exceptions.rst @@ -5,6 +5,8 @@ .. automodule:: pyramid.exceptions + .. autoexception:: BadCSRFOrigin + .. autoexception:: BadCSRFToken .. autoexception:: PredicateMismatch diff --git a/docs/api/session.rst b/docs/api/session.rst index 474e2bb32..56c4f52d7 100644 --- a/docs/api/session.rst +++ b/docs/api/session.rst @@ -9,6 +9,8 @@ .. autofunction:: signed_deserialize + .. autofunction:: check_csrf_origin + .. autofunction:: check_csrf_token .. autofunction:: SignedCookieSessionFactory diff --git a/docs/narr/sessions.rst b/docs/narr/sessions.rst index d66e86258..7cf96ac7d 100644 --- a/docs/narr/sessions.rst +++ b/docs/narr/sessions.rst @@ -391,8 +391,8 @@ will return ``True``, otherwise it will raise ``HTTPBadRequest``. Optionally, you can specify ``raises=False`` to have the check return ``False`` instead of raising an exception. -By default, it checks for a GET or POST parameter named ``csrf_token`` or a -header named ``X-CSRF-Token``. +By default, it checks for a POST parameter named ``csrf_token`` or a header +named ``X-CSRF-Token``. .. code-block:: python @@ -411,15 +411,16 @@ Checking CSRF Tokens Automatically .. versionadded:: 1.7 -:app:`Pyramid` supports automatically checking CSRF tokens on POST requests. -Any other request may be checked manually. This feature can be turned on -globally for an application using the ``pyramid.require_default_csrf`` setting. +:app:`Pyramid` supports automatically checking CSRF tokens on requests with an +unsafe method as defined by RFC2616. Any other request may be checked manually. +This feature can be turned on globally for an application using the +``pyramid.require_default_csrf`` setting. If the ``pyramid.required_default_csrf`` setting is a :term:`truthy string` or ``True`` then the default CSRF token parameter will be ``csrf_token``. If a different token is desired, it may be passed as the value. Finally, a :term:`falsey string` or ``False`` will turn off automatic CSRF checking -globally on every POST request. +globally on every request. No matter what, CSRF checking may be explicitly enabled or disabled on a per-view basis using the ``require_csrf`` view option. This option is of the @@ -430,13 +431,20 @@ If ``require_csrf`` is ``True`` but does not explicitly define a token to check, then the token name is pulled from whatever was set in the ``pyramid.require_default_csrf`` setting. Finally, if that setting does not explicitly define a token, then ``csrf_token`` is the token required. This token -name will be required in ``request.params`` which is a combination of the -query string and a submitted form body. +name will be required in ``request.POST`` which is the submitted form body. It is always possible to pass the token in the ``X-CSRF-Token`` header as well. There is currently no way to define an alternate name for this header without performing CSRF checking manually. +In addition to token based CSRF checks, the automatic CSRF checking will also +check the referrer of the request to ensure that it matches one of the trusted +origins. By default the only trusted origin is the current host, however +additional origins may be configured by setting +``pyramid.csrf_trusted_origins`` to a list of domain names (and ports if they +are non standard). If a host in the list of domains starts with a ``.`` then +that will allow all subdomains as well as the domain without the ``.``. + If CSRF checks fail then a :class:`pyramid.exceptions.BadCSRFToken` exception will be raised. This exception may be caught and handled by an :term:`exception view` but, by default, will result in a ``400 Bad Request`` diff --git a/docs/narr/viewconfig.rst b/docs/narr/viewconfig.rst index e645185f5..cd5b8feb0 100644 --- a/docs/narr/viewconfig.rst +++ b/docs/narr/viewconfig.rst @@ -195,10 +195,11 @@ Non-Predicate Arguments ``require_csrf`` - CSRF checks only affect POST requests. Any other request methods will pass - untouched. This option is used in combination with the - ``pyramid.require_default_csrf`` setting to control which request parameters - are checked for CSRF tokens. + CSRF checks will affect any request method that is not defined as a "safe" + method by RFC2616. In pratice this means that GET, HEAD, OPTIONS, and TRACE + methods will pass untouched and all others methods will require CSRF. This + option is used in combination with the ``pyramid.require_default_csrf`` + setting to control which request parameters are checked for CSRF tokens. This feature requires a configured :term:`session factory`. @@ -214,6 +215,9 @@ Non-Predicate Arguments If this option is set to ``False`` then CSRF checks will be disabled regardless of the ``pyramid.require_default_csrf`` setting. + In addition, if this option is set to ``True`` or a string then CSRF origin + checking will be enabled. + See :ref:`auto_csrf_checking` for more information. .. versionadded:: 1.7 @@ -459,7 +463,7 @@ configured view. check name. If CSRF checking is performed, the checked value will be the value of - ``request.params[check_name]``. This value will be compared against the + ``request.POST[check_name]``. This value will be compared against the value of ``request.session.get_csrf_token()``, and the check will pass if these two values are the same. If the check passes, the associated view will be permitted to execute. If the check fails, the associated view will not be diff --git a/docs/whatsnew-1.7.rst b/docs/whatsnew-1.7.rst index d202a4140..fd144a24a 100644 --- a/docs/whatsnew-1.7.rst +++ b/docs/whatsnew-1.7.rst @@ -15,8 +15,9 @@ Backwards Incompatibilities ``md5`` to ``sha512``. If you are using the authentication policy and need to continue using ``md5``, please explicitly set ``hashalg='md5'``. - This change means that any existing auth tickets (and associated cookies) - will no longer be valid, users will be logged out, and have to login to their + If you are not currently specifying the ``hashalg`` option in your apps, then + this change means any existing auth tickets (and associated cookies) will no + longer be valid, users will be logged out, and have to login to their accounts again. This change has been issuing a DeprecationWarning since :app:`Pyramid` 1.4. @@ -27,6 +28,10 @@ Backwards Incompatibilities https://github.com/Pylons/pyramid/issues/2368 and https://github.com/Pylons/pyramid/pull/2256 +- The :func:`pyramid.session.check_csrf_token` function no longer validates a + csrf token in the query string of a request. Only headers and request bodies + are supported. See https://github.com/Pylons/pyramid/pull/2500 + Feature Additions ----------------- @@ -38,21 +43,38 @@ Feature Additions to security checks. See https://github.com/Pylons/pyramid/pull/2021 - Added a new setting, ``pyramid.require_default_csrf`` which may be used - to turn on CSRF checks globally for every POST request in the application. + to turn on CSRF checks globally for every request in the application. This should be considered a good default for websites built on Pyramid. It is possible to opt-out of CSRF checks on a per-view basis by setting ``require_csrf=False`` on those views. See :ref:`auto_csrf_checking` and https://github.com/Pylons/pyramid/pull/2413 -- Added a ``require_csrf`` view option which will enforce CSRF checks on POST - requests. If the CSRF check fails a ``BadCSRFToken`` exception will be - raised and may be caught by exception views (the default response is a - ``400 Bad Request``). This option should be used in place of the deprecated - ``check_csrf`` view predicate which would normally result in unexpected - ``404 Not Found`` response to the client instead of a catchable exception. - See :ref:`auto_csrf_checking` and - https://github.com/Pylons/pyramid/pull/2413 +- Added a ``require_csrf`` view option which will enforce CSRF checks on + requests with an unsafe method as defined by RFC2616. If the CSRF check fails + a ``BadCSRFToken`` exception will be raised and may be caught by exception + views (the default response is a ``400 Bad Request``). This option should be + used in place of the deprecated ``check_csrf`` view predicate which would + normally result in unexpected ``404 Not Found`` response to the client + instead of a catchable exception. See :ref:`auto_csrf_checking`, + https://github.com/Pylons/pyramid/pull/2413 and + https://github.com/Pylons/pyramid/pull/2500 + +- Added an additional CSRF validation that checks the origin/referrer of a + request and makes sure it matches the current ``request.domain``. This + particular check is only active when accessing a site over HTTPS as otherwise + browsers don't always send the required information. If this additional CSRF + validation fails a ``BadCSRFOrigin`` exception will be raised and may be + caught by exception views (the default response is ``400 Bad Request``). + Additional allowed origins may be configured by setting + ``pyramid.csrf_trusted_origins`` to a list of domain names (with ports if on + a non standard port) to allow. Subdomains are not allowed unless the domain + name has been prefixed with a ``.``. See + https://github.com/Pylons/pyramid/pull/2501 + +- Added a new :func:`pyramid.session.check_csrf_origin` API for validating the + origin or referrer headers against the request's domain. + See https://github.com/Pylons/pyramid/pull/2501 - Subclasses of :class:`pyramid.httpexceptions.HTTPException` will now take into account the best match for the clients ``Accept`` header, and depending @@ -64,7 +86,8 @@ Feature Additions - A new event, :class:`pyramid.events.BeforeTraversal`, and interface :class:`pyramid.interfaces.IBeforeTraversal` have been introduced that will notify listeners before traversal starts in the router. - See https://github.com/Pylons/pyramid/pull/2469 and + See :ref:`router_chapter` as well as + https://github.com/Pylons/pyramid/pull/2469 and https://github.com/Pylons/pyramid/pull/1876 - A new method, :meth:`pyramid.request.Request.invoke_exception_view`, which @@ -106,6 +129,7 @@ Scaffolding Enhancements practices with regards to SQLAlchemy session management, as well as a more modular approach to configuration, separating routes into a separate module to illustrate uses of :meth:`pyramid.config.Configurator.include`. + See https://github.com/Pylons/pyramid/pull/2024 Documentation Enhancements -------------------------- diff --git a/pyramid/config/settings.py b/pyramid/config/settings.py index b66986327..9e5c3b62d 100644 --- a/pyramid/config/settings.py +++ b/pyramid/config/settings.py @@ -124,6 +124,8 @@ class Settings(dict): config_prevent_cachebust)) require_default_csrf = self.get('pyramid.require_default_csrf') eff_require_default_csrf = require_default_csrf + csrf_trusted_origins = self.get("pyramid.csrf_trusted_origins", []) + eff_csrf_trusted_origins = csrf_trusted_origins update = { 'debug_authorization': eff_debug_all or eff_debug_auth, @@ -137,6 +139,7 @@ class Settings(dict): 'prevent_http_cache':eff_prevent_http_cache, 'prevent_cachebust':eff_prevent_cachebust, 'require_default_csrf':eff_require_default_csrf, + 'csrf_trusted_origins':eff_csrf_trusted_origins, 'pyramid.debug_authorization': eff_debug_all or eff_debug_auth, 'pyramid.debug_notfound': eff_debug_all or eff_debug_notfound, @@ -149,6 +152,7 @@ class Settings(dict): 'pyramid.prevent_http_cache':eff_prevent_http_cache, 'pyramid.prevent_cachebust':eff_prevent_cachebust, 'pyramid.require_default_csrf':eff_require_default_csrf, + 'pyramid.csrf_trusted_origins':eff_csrf_trusted_origins, } self.update(update) diff --git a/pyramid/exceptions.py b/pyramid/exceptions.py index c1481ce9c..a8a10f927 100644 --- a/pyramid/exceptions.py +++ b/pyramid/exceptions.py @@ -9,6 +9,21 @@ Forbidden = HTTPForbidden # bw compat CR = '\n' + +class BadCSRFOrigin(HTTPBadRequest): + """ + This exception indicates the request has failed cross-site request forgery + origin validation. + """ + title = "Bad CSRF Origin" + explanation = ( + "Access is denied. This server can not verify that the origin or " + "referrer of your request matches the current site. Either your " + "browser supplied the wrong Origin or Referrer or it did not supply " + "one at all." + ) + + class BadCSRFToken(HTTPBadRequest): """ This exception indicates the request has failed cross-site request diff --git a/pyramid/session.py b/pyramid/session.py index fd7b5f8d5..36ebc2f00 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -16,11 +16,19 @@ from pyramid.compat import ( text_, bytes_, native_, + urlparse, ) -from pyramid.exceptions import BadCSRFToken +from pyramid.exceptions import ( + BadCSRFOrigin, + BadCSRFToken, +) from pyramid.interfaces import ISession -from pyramid.util import strings_differ +from pyramid.settings import aslist +from pyramid.util import ( + is_same_domain, + strings_differ, +) def manage_accessed(wrapped): """ Decorator which causes a cookie to be renewed when an accessor @@ -101,18 +109,110 @@ def signed_deserialize(serialized, secret, hmac=hmac): return pickle.loads(pickled) + +def check_csrf_origin(request, trusted_origins=None, raises=True): + """ + Check the Origin of the request to see if it is a cross site request or + not. + + If the value supplied by the Origin or Referer header isn't one of the + trusted origins and ``raises`` is ``True``, this function will raise a + :exc:`pyramid.exceptions.BadCSRFOrigin` exception but if ``raises`` is + ``False`` this function will return ``False`` instead. If the CSRF origin + checks are successful this function will return ``True`` unconditionally. + + Additional trusted origins may be added by passing a list of domain (and + ports if nonstandard like `['example.com', 'dev.example.com:8080']`) in + with the ``trusted_origins`` parameter. If ``trusted_origins`` is ``None`` + (the default) this list of additional domains will be pulled from the + ``pyramid.csrf_trusted_origins`` setting. + + Note that this function will do nothing if request.scheme is not https. + + .. versionadded:: 1.7 + """ + def _fail(reason): + if raises: + raise BadCSRFOrigin(reason) + else: + return False + + if request.scheme == "https": + # Suppose user visits http://example.com/ + # An active network attacker (man-in-the-middle, MITM) sends a + # POST form that targets https://example.com/detonate-bomb/ and + # submits it via JavaScript. + # + # The attacker will need to provide a CSRF cookie and token, but + # that's no problem for a MITM when we cannot make any assumptions + # about what kind of session storage is being used. So the MITM can + # circumvent the CSRF protection. This is true for any HTTP connection, + # but anyone using HTTPS expects better! For this reason, for + # https://example.com/ we need additional protection that treats + # http://example.com/ as completely untrusted. Under HTTPS, + # Barth et al. found that the Referer header is missing for + # same-domain requests in only about 0.2% of cases or less, so + # we can use strict Referer checking. + + # Determine the origin of this request + origin = request.headers.get("Origin") + if origin is None: + origin = request.referrer + + # Fail if we were not able to locate an origin at all + if not origin: + return _fail("Origin checking failed - no Origin or Referer.") + + # Parse our origin so we we can extract the required information from + # it. + originp = urlparse.urlparse(origin) + + # Ensure that our Referer is also secure. + if originp.scheme != "https": + return _fail( + "Referer checking failed - Referer is insecure while host is " + "secure." + ) + + # Determine which origins we trust, which by default will include the + # current origin. + if trusted_origins is None: + trusted_origins = aslist( + request.registry.settings.get( + "pyramid.csrf_trusted_origins", []) + ) + + if request.host_port not in set([80, 443]): + trusted_origins.append("{0.domain}:{0.host_port}".format(request)) + else: + trusted_origins.append(request.domain) + + # Actually check to see if the request's origin matches any of our + # trusted origins. + if not any(is_same_domain(originp.netloc, host) + for host in trusted_origins): + reason = ( + "Referer checking failed - {0} does not match any trusted " + "origins." + ) + return _fail(reason.format(origin)) + + return True + + def check_csrf_token(request, token='csrf_token', header='X-CSRF-Token', raises=True): """ Check the CSRF token in the request's session against the value in - ``request.params.get(token)`` 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.params``. - If a ``header`` keyword is not supplied to this function, the string - ``X-CSRF-Token`` will be used to look up the token in ``request.headers``. - - If the value supplied by param or by header doesn't match the value + ``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 + function, the string ``X-CSRF-Token`` will be used to look up the token in + ``request.headers``. + + If the value supplied by post 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.exceptions.BadCSRFToken` exception. @@ -127,8 +227,24 @@ def check_csrf_token(request, application automatically against CSRF attacks. .. versionadded:: 1.4a2 + + .. versionchanged:: 1.7a1 + A CSRF token passed in the query string of the request is no longer + considered valid. It must be passed in either the request body or + a header. """ - supplied_token = request.params.get(token, request.headers.get(header, "")) + # If this is a POST/PUT/etc request, then we'll check the body to see if it + # has a token. We explicitly use request.POST here because CSRF tokens + # should never appear in an URL as doing so is a security issue. We also + # explicitly check for request.POST here as we do not support sending form + # encoded data over anything but a request.POST. + supplied_token = request.POST.get(token, "") + + # If we were unable to locate a CSRF token in a request body, then we'll + # check to see if there are any headers that have a value for us. + if supplied_token == "": + supplied_token = request.headers.get(header, "") + expected_token = request.session.get_csrf_token() if strings_differ(bytes_(expected_token), bytes_(supplied_token)): if raises: diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 0bf0bd0b3..21ed24f44 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -1502,8 +1502,9 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertEqual(len(w), 1) wrapper = self._getViewCallable(config) request = self._makeRequest(config) + request.method = "POST" request.session = DummySession({'csrf_token': 'foo'}) - request.params = {'csrf_token': 'foo'} + request.POST = {'csrf_token': 'foo'} request.headers = {} self.assertEqual(wrapper(None, request), 'OK') @@ -1594,8 +1595,9 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view, require_csrf='st', renderer=null_renderer) view = self._getViewCallable(config) request = self._makeRequest(config) + request.scheme = "http" request.method = 'POST' - request.params = {'st': 'foo'} + request.POST = {'st': 'foo'} request.headers = {} request.session = DummySession({'csrf_token': 'foo'}) self.assertEqual(view(None, request), 'OK') @@ -1608,7 +1610,9 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view, require_csrf=True, renderer=null_renderer) view = self._getViewCallable(config) request = self._makeRequest(config) + request.scheme = "http" request.method = 'POST' + request.POST = {} request.headers = {'X-CSRF-Token': 'foo'} request.session = DummySession({'csrf_token': 'foo'}) self.assertEqual(view(None, request), 'OK') @@ -1621,7 +1625,9 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view, require_csrf=True, renderer=null_renderer) view = self._getViewCallable(config) request = self._makeRequest(config) + request.scheme = "http" request.method = 'POST' + request.POST = {} request.headers = {} request.session = DummySession({'csrf_token': 'foo'}) self.assertRaises(BadCSRFToken, lambda: view(None, request)) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 914d28a83..e08f9a919 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -666,7 +666,8 @@ class Test_check_csrf_token(unittest.TestCase): def test_success_token(self): request = testing.DummyRequest() - request.params['csrf_token'] = request.session.get_csrf_token() + request.method = "POST" + request.POST = {'csrf_token': request.session.get_csrf_token()} self.assertEqual(self._callFUT(request, token='csrf_token'), True) def test_success_header(self): @@ -676,7 +677,8 @@ class Test_check_csrf_token(unittest.TestCase): def test_success_default_token(self): request = testing.DummyRequest() - request.params['csrf_token'] = request.session.get_csrf_token() + request.method = "POST" + request.POST = {'csrf_token': request.session.get_csrf_token()} self.assertEqual(self._callFUT(request), True) def test_success_default_header(self): @@ -698,10 +700,104 @@ class Test_check_csrf_token(unittest.TestCase): def test_token_differing_types(self): from pyramid.compat import text_ request = testing.DummyRequest() + request.method = "POST" request.session['_csrft_'] = text_('foo') - request.params['csrf_token'] = b'foo' + request.POST = {'csrf_token': b'foo'} self.assertEqual(self._callFUT(request, token='csrf_token'), True) + +class Test_check_csrf_origin(unittest.TestCase): + + def _callFUT(self, *args, **kwargs): + from ..session import check_csrf_origin + return check_csrf_origin(*args, **kwargs) + + def test_success_with_http(self): + request = testing.DummyRequest() + request.scheme = "http" + self.assertTrue(self._callFUT(request)) + + def test_success_with_https_and_referrer(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = 443 + request.referrer = "https://example.com/login/" + request.registry.settings = {} + self.assertTrue(self._callFUT(request)) + + def test_success_with_https_and_origin(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = 443 + request.headers = {"Origin": "https://example.com/"} + request.referrer = "https://not-example.com/" + request.registry.settings = {} + self.assertTrue(self._callFUT(request)) + + def test_success_with_additional_trusted_host(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = 443 + request.referrer = "https://not-example.com/login/" + request.registry.settings = { + "pyramid.csrf_trusted_origins": ["not-example.com"], + } + self.assertTrue(self._callFUT(request)) + + def test_success_with_nonstandard_port(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com:8080" + request.host_port = 8080 + request.referrer = "https://example.com:8080/login/" + request.registry.settings = {} + self.assertTrue(self._callFUT(request)) + + def test_fails_with_wrong_host(self): + from pyramid.exceptions import BadCSRFOrigin + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = 443 + request.referrer = "https://not-example.com/login/" + request.registry.settings = {} + self.assertRaises(BadCSRFOrigin, self._callFUT, request) + self.assertFalse(self._callFUT(request, raises=False)) + + def test_fails_with_no_origin(self): + from pyramid.exceptions import BadCSRFOrigin + request = testing.DummyRequest() + request.scheme = "https" + request.referrer = None + self.assertRaises(BadCSRFOrigin, self._callFUT, request) + self.assertFalse(self._callFUT(request, raises=False)) + + def test_fails_when_http_to_https(self): + from pyramid.exceptions import BadCSRFOrigin + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = 443 + request.referrer = "http://example.com/evil/" + request.registry.settings = {} + self.assertRaises(BadCSRFOrigin, self._callFUT, request) + self.assertFalse(self._callFUT(request, raises=False)) + + def test_fails_with_nonstandard_port(self): + from pyramid.exceptions import BadCSRFOrigin + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com:8080" + request.host_port = 8080 + request.referrer = "https://example.com/login/" + request.registry.settings = {} + self.assertRaises(BadCSRFOrigin, self._callFUT, request) + self.assertFalse(self._callFUT(request, raises=False)) + + class DummySerializer(object): def dumps(self, value): return base64.b64encode(json.dumps(value).encode('utf-8')) diff --git a/pyramid/tests/test_util.py b/pyramid/tests/test_util.py index c606a4b6b..bbf6103f4 100644 --- a/pyramid/tests/test_util.py +++ b/pyramid/tests/test_util.py @@ -856,3 +856,24 @@ def dummyfunc(): pass class Dummy(object): pass + + +class Test_is_same_domain(unittest.TestCase): + def _callFUT(self, *args, **kw): + from pyramid.util import is_same_domain + return is_same_domain(*args, **kw) + + def test_it(self): + self.assertTrue(self._callFUT("example.com", "example.com")) + self.assertFalse(self._callFUT("evil.com", "example.com")) + self.assertFalse(self._callFUT("evil.example.com", "example.com")) + self.assertFalse(self._callFUT("example.com", "")) + + def test_with_wildcard(self): + self.assertTrue(self._callFUT("example.com", ".example.com")) + self.assertTrue(self._callFUT("good.example.com", ".example.com")) + + def test_with_port(self): + self.assertTrue(self._callFUT("example.com:8080", "example.com:8080")) + self.assertFalse(self._callFUT("example.com:8080", "example.com")) + self.assertFalse(self._callFUT("example.com", "example.com:8080")) diff --git a/pyramid/tests/test_viewderivers.py b/pyramid/tests/test_viewderivers.py index c8fbe6f36..6bfe353e5 100644 --- a/pyramid/tests/test_viewderivers.py +++ b/pyramid/tests/test_viewderivers.py @@ -1119,7 +1119,9 @@ class TestDeriveView(unittest.TestCase): def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' + request.POST = {} request.session = DummySession({'csrf_token': 'foo'}) request.headers = {'X-CSRF-Token': 'foo'} view = self.config._derive_view(inner_view, require_csrf=True) @@ -1131,9 +1133,26 @@ class TestDeriveView(unittest.TestCase): def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['DUMMY'] = 'foo' + request.POST = {'DUMMY': 'foo'} + view = self.config._derive_view(inner_view, require_csrf='DUMMY') + result = view(None, request) + self.assertTrue(result is response) + + def test_csrf_view_https_domain(self): + response = DummyResponse() + def inner_view(request): + return response + request = self._makeRequest() + request.scheme = "https" + request.domain = "example.com" + request.host_port = 443 + request.referrer = "https://example.com/login/" + request.method = 'POST' + request.session = DummySession({'csrf_token': 'foo'}) + request.POST = {'DUMMY': 'foo'} view = self.config._derive_view(inner_view, require_csrf='DUMMY') result = view(None, request) self.assertTrue(result is response) @@ -1152,9 +1171,10 @@ class TestDeriveView(unittest.TestCase): from pyramid.exceptions import BadCSRFToken def inner_view(request): pass request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['DUMMY'] = 'bar' + request.POST = {'DUMMY': 'bar'} view = self.config._derive_view(inner_view, require_csrf='DUMMY') self.assertRaises(BadCSRFToken, lambda: view(None, request)) @@ -1162,20 +1182,61 @@ class TestDeriveView(unittest.TestCase): from pyramid.exceptions import BadCSRFToken def inner_view(request): pass request = self._makeRequest() + request.scheme = "http" request.method = 'POST' + request.POST = {} request.session = DummySession({'csrf_token': 'foo'}) request.headers = {'X-CSRF-Token': 'bar'} view = self.config._derive_view(inner_view, require_csrf='DUMMY') self.assertRaises(BadCSRFToken, lambda: view(None, request)) + def test_csrf_view_fails_on_bad_PUT_header(self): + from pyramid.exceptions import BadCSRFToken + def inner_view(request): pass + request = self._makeRequest() + request.scheme = "http" + request.method = 'PUT' + request.POST = {} + request.session = DummySession({'csrf_token': 'foo'}) + request.headers = {'X-CSRF-Token': 'bar'} + view = self.config._derive_view(inner_view, require_csrf='DUMMY') + self.assertRaises(BadCSRFToken, lambda: view(None, request)) + + def test_csrf_view_fails_on_bad_referrer(self): + from pyramid.exceptions import BadCSRFOrigin + def inner_view(request): pass + request = self._makeRequest() + request.method = "POST" + request.scheme = "https" + request.host_port = 443 + request.domain = "example.com" + request.referrer = "https://not-example.com/evil/" + request.registry.settings = {} + view = self.config._derive_view(inner_view, require_csrf='DUMMY') + self.assertRaises(BadCSRFOrigin, lambda: view(None, request)) + + def test_csrf_view_fails_on_bad_origin(self): + from pyramid.exceptions import BadCSRFOrigin + def inner_view(request): pass + request = self._makeRequest() + request.method = "POST" + request.scheme = "https" + request.host_port = 443 + request.domain = "example.com" + request.headers = {"Origin": "https://not-example.com/evil/"} + request.registry.settings = {} + view = self.config._derive_view(inner_view, require_csrf='DUMMY') + self.assertRaises(BadCSRFOrigin, lambda: view(None, request)) + def test_csrf_view_uses_config_setting_truthy(self): response = DummyResponse() def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['csrf_token'] = 'foo' + request.POST = {'csrf_token': 'foo'} self.config.add_settings({'pyramid.require_default_csrf': 'yes'}) view = self.config._derive_view(inner_view) result = view(None, request) @@ -1186,9 +1247,10 @@ class TestDeriveView(unittest.TestCase): def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['DUMMY'] = 'foo' + request.POST = {'DUMMY': 'foo'} self.config.add_settings({'pyramid.require_default_csrf': 'DUMMY'}) view = self.config._derive_view(inner_view) result = view(None, request) @@ -1212,9 +1274,10 @@ class TestDeriveView(unittest.TestCase): def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['DUMMY'] = 'foo' + request.POST = {'DUMMY': 'foo'} self.config.add_settings({'pyramid.require_default_csrf': 'yes'}) view = self.config._derive_view(inner_view, require_csrf='DUMMY') result = view(None, request) @@ -1225,9 +1288,10 @@ class TestDeriveView(unittest.TestCase): def inner_view(request): return response request = self._makeRequest() + request.scheme = "http" request.method = 'POST' request.session = DummySession({'csrf_token': 'foo'}) - request.params['DUMMY'] = 'foo' + request.POST = {'DUMMY': 'foo'} self.config.add_settings({'pyramid.require_default_csrf': 'DUMMY'}) view = self.config._derive_view(inner_view, require_csrf=True) result = view(None, request) diff --git a/pyramid/util.py b/pyramid/util.py index fc1d52af5..4936dcb24 100644 --- a/pyramid/util.py +++ b/pyramid/util.py @@ -614,3 +614,20 @@ def hide_attrs(obj, *attrs): obj_vals[name] = saved_val elif name in obj_vals: del obj_vals[name] + + +def is_same_domain(host, pattern): + """ + Return ``True`` if the host is either an exact match or a match + to the wildcard pattern. + Any pattern beginning with a period matches a domain and all of its + subdomains. (e.g. ``.example.com`` matches ``example.com`` and + ``foo.example.com``). Anything else is an exact string match. + """ + if not pattern: + return False + + pattern = pattern.lower() + return (pattern[0] == "." and + (host.endswith(pattern) or host == pattern[1:]) or + pattern == host) diff --git a/pyramid/viewderivers.py b/pyramid/viewderivers.py index 41102319d..d5a5c480a 100644 --- a/pyramid/viewderivers.py +++ b/pyramid/viewderivers.py @@ -6,7 +6,10 @@ from zope.interface import ( ) from pyramid.security import NO_PERMISSION_REQUIRED -from pyramid.session import check_csrf_token +from pyramid.session import ( + check_csrf_origin, + check_csrf_token, +) from pyramid.response import Response from pyramid.interfaces import ( @@ -474,6 +477,8 @@ def _parse_csrf_setting(val, error_source): .format(error_source)) return val +SAFE_REQUEST_METHODS = frozenset(["GET", "HEAD", "OPTIONS", "TRACE"]) + def csrf_view(view, info): default_val = _parse_csrf_setting( info.settings.get('pyramid.require_default_csrf'), @@ -488,7 +493,10 @@ def csrf_view(view, info): wrapped_view = view if val: def csrf_view(context, request): - if request.method == 'POST': + # Assume that anything not defined as 'safe' by RFC2616 needs + # protection + if request.method not in SAFE_REQUEST_METHODS: + check_csrf_origin(request, raises=True) check_csrf_token(request, val, raises=True) return view(context, request) wrapped_view = csrf_view |
