From 8b7b7cbf9058312f0bf6b044cfa388f807eff739 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 30 Sep 2019 21:27:20 -0500 Subject: [PATCH] support Origin: null in csrf_trusted_origins and check_origin=False --- CHANGES.rst | 11 ++- docs/narr/security.rst | 7 +- src/pyramid/config/security.py | 17 +++- src/pyramid/csrf.py | 148 ++++++++++++++++------------- src/pyramid/viewderivers.py | 9 +- tests/test_config/test_security.py | 7 +- tests/test_csrf.py | 42 +++++++- tests/test_viewderivers.py | 22 +++++ 8 files changed, 186 insertions(+), 77 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0cf66d16fe..987d5c3d4a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,8 +43,17 @@ Features ``pyramid.csrf.check_csrf_origin``. This option controls whether a request is rejected if it has no ``Origin`` or ``Referer`` header - often the result of a user configuring their browser not to send a - ``Referer`` header for privacy reasons. + ``Referer`` header for privacy reasons even on same-domain requests. + The default is to reject requests without a known origin. It is also + possible to allow the special ``Origin: null`` header by adding it to the + ``pyramid.csrf_trusted_origins`` list in the settings. See https://github.com/Pylons/pyramid/pull/3512 + and https://github.com/Pylons/pyramid/pull/3518 + +- A new parameter, ``check_origin``, was added to + ``pyramid.config.Configurator.set_default_csrf_options`` which disables + origin checking entirely. + See https://github.com/Pylons/pyramid/pull/3518 - Added ``pyramid.interfaces.IPredicateInfo`` which defines the object passed to predicate factories as their second argument. diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 9d45dfb6a7..f1bb37c695 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -885,7 +885,12 @@ is the current host, however additional origins may be configured by setting 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 no ``Referer`` or ``Origin`` header is present in an HTTPS request, the CSRF check -will fail unless ``allow_no_origin`` is set. +will fail unless ``allow_no_origin`` is set. The special ``Origin: null`` can +be allowed by adding ``null`` to the ``pyramid.csrf_trusted_origins`` list. + +It is possible to opt out of checking the origin by passing +``check_origin=False``. This is useful if the :term:`CSRF storage policy` is +known to be secure such that the token cannot be easily used by an attacker. If CSRF checks fail then a :class:`pyramid.exceptions.BadCSRFToken` or :class:`pyramid.exceptions.BadCSRFOrigin` exception will be raised. This diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index 32b4db03c5..99eb5792c1 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -254,6 +254,7 @@ def set_default_csrf_options( token='csrf_token', header='X-CSRF-Token', safe_methods=('GET', 'HEAD', 'OPTIONS', 'TRACE'), + check_origin=True, allow_no_origin=False, callback=None, ): @@ -279,8 +280,13 @@ def set_default_csrf_options( never be automatically checked for CSRF tokens. Default: ``('GET', 'HEAD', 'OPTIONS', TRACE')``. - ``allow_no_origin`` is a boolean. If false, a request lacking both an - ``Origin`` and ``Referer`` header will fail the CSRF check. + ``check_origin`` is a boolean. If ``False``, the ``Origin`` and + ``Referer`` headers will not be validated as part of automated + CSRF checks. + + ``allow_no_origin`` is a boolean. If ``True``, a request lacking both + an ``Origin`` and ``Referer`` header will pass the CSRF check. This + option has no effect if ``check_origin`` is ``False``. If ``callback`` is set, it must be a callable accepting ``(request)`` and returning ``True`` if the request should be checked for a valid @@ -298,7 +304,7 @@ def set_default_csrf_options( Added the ``callback`` option. .. versionchanged:: 2.0 - Added the ``allow_no_origin`` option. + Added the ``allow_no_origin`` and ``check_origin`` options. """ options = DefaultCSRFOptions( @@ -306,6 +312,7 @@ def set_default_csrf_options( token=token, header=header, safe_methods=safe_methods, + check_origin=check_origin, allow_no_origin=allow_no_origin, callback=callback, ) @@ -323,6 +330,8 @@ def register(): intr['token'] = token intr['header'] = header intr['safe_methods'] = as_sorted_tuple(safe_methods) + intr['check_origin'] = allow_no_origin + intr['allow_no_origin'] = check_origin intr['callback'] = callback self.action( @@ -362,6 +371,7 @@ def __init__( token, header, safe_methods, + check_origin, allow_no_origin, callback, ): @@ -369,5 +379,6 @@ def __init__( self.token = token self.header = header self.safe_methods = frozenset(safe_methods) + self.check_origin = check_origin self.allow_no_origin = allow_no_origin self.callback = callback diff --git a/src/pyramid/csrf.py b/src/pyramid/csrf.py index b352ada716..f9914e852f 100644 --- a/src/pyramid/csrf.py +++ b/src/pyramid/csrf.py @@ -248,7 +248,7 @@ def check_csrf_token( def check_csrf_origin( - request, trusted_origins=None, allow_no_origin=False, raises=True + request, *, trusted_origins=None, allow_no_origin=False, raises=True ): """ Check the ``Origin`` of the request to see if it is a cross site request or @@ -266,6 +266,10 @@ def check_csrf_origin( (the default) this list of additional domains will be pulled from the ``pyramid.csrf_trusted_origins`` setting. + ``allow_no_origin`` determines whether to return ``True`` when the + origin cannot be determined via either the ``Referer`` or ``Origin`` + header. The default is ``False`` which will reject the check. + Note that this function will do nothing if ``request.scheme`` is not ``https``. @@ -274,78 +278,90 @@ def check_csrf_origin( .. versionchanged:: 1.9 Moved from :mod:`pyramid.session` to :mod:`pyramid.csrf` + .. versionchanged:: 2.0 + Added the ``allow_no_origin`` option. + """ def _fail(reason): if raises: - raise BadCSRFOrigin(reason) + raise BadCSRFOrigin("Origin checking failed - " + 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 - - # If we can't find an origin, fail or pass immediately depending on - # ``allow_no_origin`` - if not origin: - if allow_no_origin: - return True - else: - return _fail("Origin checking failed - no Origin or Referer.") - - # Parse our origin so we we can extract the required information from - # it. - originp = 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)) + # Origin checks are only trustworthy / useful on HTTPS requests. + if request.scheme != "https": + return True + + # 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") + origin_is_referrer = False + if origin is None: + origin = request.referrer + origin_is_referrer = True + + else: + # use the last origin in the list under the assumption that the + # server generally appends values and we want the origin closest + # to us + origin = origin.split(' ')[-1] + + # If we can't find an origin, fail or pass immediately depending on + # ``allow_no_origin`` + if not origin: + if allow_no_origin: + return True 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 _fail("missing Origin or Referer.") + + # 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) + + # Check "Origin: null" against trusted_origins + if not origin_is_referrer and origin == 'null': + if origin in trusted_origins: + return True + else: + return _fail("null does not match any trusted origins.") + + # Parse our origin so we we can extract the required information from + # it. + originp = urlparse(origin) + + # Ensure that our Referer is also secure. + if originp.scheme != "https": + return _fail("Origin is insecure while host is secure.") + + # 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 + ): + return _fail("{0} does not match any trusted origins.".format(origin)) return True diff --git a/src/pyramid/viewderivers.py b/src/pyramid/viewderivers.py index 95c223e614..35f9a08d24 100644 --- a/src/pyramid/viewderivers.py +++ b/src/pyramid/viewderivers.py @@ -484,6 +484,7 @@ def csrf_view(view, info): token = 'csrf_token' header = 'X-CSRF-Token' safe_methods = frozenset(["GET", "HEAD", "OPTIONS", "TRACE"]) + check_origin = True allow_no_origin = False callback = None else: @@ -491,6 +492,7 @@ def csrf_view(view, info): token = defaults.token header = defaults.header safe_methods = defaults.safe_methods + check_origin = defaults.check_origin allow_no_origin = defaults.allow_no_origin callback = defaults.callback @@ -510,9 +512,10 @@ def csrf_view(context, request): if request.method not in safe_methods and ( callback is None or callback(request) ): - check_csrf_origin( - request, raises=True, allow_no_origin=allow_no_origin - ) + if check_origin: + check_csrf_origin( + request, raises=True, allow_no_origin=allow_no_origin + ) check_csrf_token(request, token, header, raises=True) return view(context, request) diff --git a/tests/test_config/test_security.py b/tests/test_config/test_security.py index 0ae199239c..9a9ea9f7ef 100644 --- a/tests/test_config/test_security.py +++ b/tests/test_config/test_security.py @@ -158,6 +158,7 @@ def test_set_default_csrf_options(self): list(sorted(result.safe_methods)), ['GET', 'HEAD', 'OPTIONS', 'TRACE'], ) + self.assertTrue(result.check_origin) self.assertFalse(result.allow_no_origin) self.assertTrue(result.callback is None) @@ -174,7 +175,8 @@ def callback(request): # pragma: no cover token='DUMMY', header=None, safe_methods=('PUT',), - allow_no_origin=True, + check_origin=False, + allow_no_origin=False, callback=callback, ) result = config.registry.getUtility(IDefaultCSRFOptions) @@ -182,5 +184,6 @@ def callback(request): # pragma: no cover self.assertEqual(result.token, 'DUMMY') self.assertEqual(result.header, None) self.assertEqual(list(sorted(result.safe_methods)), ['PUT']) - self.assertTrue(result.allow_no_origin) + self.assertFalse(result.check_origin) + self.assertFalse(result.allow_no_origin) self.assertTrue(result.callback is callback) diff --git a/tests/test_csrf.py b/tests/test_csrf.py index f93a1afded..ae998ec955 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -387,8 +387,48 @@ def test_fails_with_no_origin(self): request = testing.DummyRequest() request.scheme = "https" request.referrer = None - self.assertRaises(BadCSRFOrigin, self._callFUT, request) + self.assertRaises( + BadCSRFOrigin, self._callFUT, request, allow_no_origin=False + ) + self.assertFalse( + self._callFUT(request, raises=False, allow_no_origin=False) + ) + + def test_fail_with_null_origin(self): + from pyramid.exceptions import BadCSRFOrigin + + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = "443" + request.referrer = None + request.headers = {'Origin': 'null'} + request.registry.settings = {} self.assertFalse(self._callFUT(request, raises=False)) + self.assertRaises(BadCSRFOrigin, self._callFUT, request) + + def test_success_with_null_origin_and_setting(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = "443" + request.referrer = None + request.headers = {'Origin': 'null'} + request.registry.settings = {"pyramid.csrf_trusted_origins": ["null"]} + self.assertTrue(self._callFUT(request, raises=False)) + + def test_success_with_multiple_origins(self): + request = testing.DummyRequest() + request.scheme = "https" + request.host = "example.com" + request.host_port = "443" + request.headers = { + 'Origin': 'https://google.com https://not-example.com' + } + request.registry.settings = { + "pyramid.csrf_trusted_origins": ["not-example.com"] + } + self.assertTrue(self._callFUT(request, raises=False)) def test_fails_when_http_to_https(self): from pyramid.exceptions import BadCSRFOrigin diff --git a/tests/test_viewderivers.py b/tests/test_viewderivers.py index 12a903eaa9..e47296b505 100644 --- a/tests/test_viewderivers.py +++ b/tests/test_viewderivers.py @@ -1414,6 +1414,28 @@ def inner_view(request): result = view(None, request) self.assertTrue(result is response) + def test_csrf_view_disables_origin_check(self): + response = DummyResponse() + + def inner_view(request): + return response + + self.config.set_default_csrf_options( + require_csrf=True, check_origin=False + ) + request = self._makeRequest() + request.scheme = "https" + request.domain = "example.com" + request.host_port = "443" + request.referrer = None + request.method = 'POST' + request.headers = {"Origin": "https://evil-example.com"} + request.session = DummySession({'csrf_token': 'foo'}) + request.POST = {'csrf_token': 'foo'} + view = self.config._derive_view(inner_view, require_csrf=True) + result = view(None, request) + self.assertTrue(result is response) + def test_csrf_view_allow_no_origin(self): response = DummyResponse()