Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add allow_no_origin option to CSRF #3512

Merged
merged 5 commits into from
Sep 30, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ Change History
.. toctree::
:maxdepth: 1

whatsnew-2.0
whatsnew-1.10
whatsnew-1.9
whatsnew-1.8
Expand Down
4 changes: 3 additions & 1 deletion docs/narr/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,9 @@ 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 ``.``.
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.

If CSRF checks fail then a :class:`pyramid.exceptions.BadCSRFToken` or
:class:`pyramid.exceptions.BadCSRFOrigin` exception will be raised. This
Expand Down
16 changes: 16 additions & 0 deletions docs/whatsnew-2.0.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
What's New in Pyramid 2.0
luhn marked this conversation as resolved.
Show resolved Hide resolved
=========================

This article explains the new features in :app:`Pyramid` version 2.0 as
compared to its predecessor, :app:`Pyramid` 1.10. It also documents backwards
incompatibilities between the two versions and deprecations added to
:app:`Pyramid` 2.0, as well as software dependency changes and notable
documentation additions.

Feature Additions
-----------------

The feature additions in Pyramid 2.0 are as follows:

- Added ``allow_no_origin`` option to :meth:`pyramid.config.Configurator.set_default_csrf_options`.
See https://github.com/Pylons/pyramid/pull/3512
25 changes: 23 additions & 2 deletions src/pyramid/config/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def set_default_csrf_options(
token='csrf_token',
header='X-CSRF-Token',
safe_methods=('GET', 'HEAD', 'OPTIONS', 'TRACE'),
allow_no_origin=False,
callback=None,
):
"""
Expand All @@ -221,6 +222,9 @@ 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.

If ``callback`` is set, it must be a callable accepting ``(request)``
and returning ``True`` if the request should be checked for a valid
CSRF token. This callback allows an application to support
Expand All @@ -236,9 +240,17 @@ def set_default_csrf_options(
.. versionchanged:: 1.8
Added the ``callback`` option.

.. versionchanged:: 2.0
Added the ``allow_no_origin`` option.

"""
options = DefaultCSRFOptions(
require_csrf, token, header, safe_methods, callback
require_csrf=require_csrf,
mmerickel marked this conversation as resolved.
Show resolved Hide resolved
token=token,
header=header,
safe_methods=safe_methods,
allow_no_origin=allow_no_origin,
callback=callback,
)

def register():
Expand Down Expand Up @@ -287,9 +299,18 @@ def register():

@implementer(IDefaultCSRFOptions)
class DefaultCSRFOptions(object):
def __init__(self, require_csrf, token, header, safe_methods, callback):
def __init__(
self,
require_csrf,
token,
header,
safe_methods,
allow_no_origin,
callback,
):
self.require_csrf = require_csrf
self.token = token
self.header = header
self.safe_methods = frozenset(safe_methods)
self.allow_no_origin = allow_no_origin
self.callback = callback
12 changes: 9 additions & 3 deletions src/pyramid/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ def check_csrf_token(
return True


def check_csrf_origin(request, trusted_origins=None, raises=True):
def check_csrf_origin(
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
not.
Expand Down Expand Up @@ -302,9 +304,13 @@ def _fail(reason):
if origin is None:
origin = request.referrer

# Fail if we were not able to locate an origin at all
# If we can't find an origin, fail or pass immediately depending on
# ``allow_no_origin``
if not origin:
return _fail("Origin checking failed - no Origin or Referer.")
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.
Expand Down
4 changes: 4 additions & 0 deletions src/pyramid/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,10 @@ class IDefaultCSRFOptions(Interface):
header = Attribute('The header to be matched with the CSRF token.')
safe_methods = Attribute('A set of safe methods that skip CSRF checks.')
callback = Attribute('A callback to disable CSRF checks per-request.')
allow_no_origin = Attribute(
'Boolean. If false, a request lacking both an ``Origin`` and '
'``Referer`` header will fail the CSRF check.'
)


class ISessionFactory(Interface):
Expand Down
6 changes: 5 additions & 1 deletion src/pyramid/viewderivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,12 +488,14 @@ def csrf_view(view, info):
token = 'csrf_token'
header = 'X-CSRF-Token'
safe_methods = frozenset(["GET", "HEAD", "OPTIONS", "TRACE"])
allow_no_origin = False
callback = None
else:
default_val = defaults.require_csrf
token = defaults.token
header = defaults.header
safe_methods = defaults.safe_methods
allow_no_origin = defaults.allow_no_origin
callback = defaults.callback

enabled = (
Expand All @@ -512,7 +514,9 @@ 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)
check_csrf_origin(
request, raises=True, allow_no_origin=allow_no_origin
)
check_csrf_token(request, token, header, raises=True)
return view(context, request)

Expand Down
3 changes: 3 additions & 0 deletions tests/test_config/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def test_set_default_csrf_options(self):
list(sorted(result.safe_methods)),
['GET', 'HEAD', 'OPTIONS', 'TRACE'],
)
self.assertFalse(result.allow_no_origin)
self.assertTrue(result.callback is None)

def test_changing_set_default_csrf_options(self):
Expand All @@ -141,11 +142,13 @@ def callback(request): # pragma: no cover
token='DUMMY',
header=None,
safe_methods=('PUT',),
allow_no_origin=True,
callback=callback,
)
result = config.registry.getUtility(IDefaultCSRFOptions)
self.assertEqual(result.require_csrf, False)
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.assertTrue(result.callback is callback)
6 changes: 6 additions & 0 deletions tests/test_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,12 @@ def test_success_with_nonstandard_port(self):
request.registry.settings = {}
self.assertTrue(self._callFUT(request))

def test_success_with_allow_no_origin(self):
request = testing.DummyRequest()
request.scheme = "https"
request.referrer = None
self.assertTrue(self._callFUT(request, allow_no_origin=True))

def test_fails_with_wrong_host(self):
from pyramid.exceptions import BadCSRFOrigin

Expand Down
21 changes: 21 additions & 0 deletions tests/test_viewderivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,27 @@ def inner_view(request):
result = view(None, request)
self.assertTrue(result is response)

def test_csrf_view_allow_no_origin(self):
response = DummyResponse()

def inner_view(request):
return response

self.config.set_default_csrf_options(
require_csrf=True, allow_no_origin=True
)
request = self._makeRequest()
request.scheme = "https"
request.domain = "example.com"
request.host_port = "443"
request.referrer = None
request.method = 'POST'
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_fails_on_bad_PUT_header(self):
from pyramid.exceptions import BadCSRFToken

Expand Down