Skip to content

Commit 21e84de

Browse files
committed
Enable CSRF protection globally by default
Enable Pyramid's `config.set_default_csrf_options(require_csrf=True)` which causes it to require a valid CSRF token for all requests with a request method that is *not* one of `GET`, `HEAD`, `OPTIONS` or `TRACE`. The CSRF token must be in a csrf_token POST parameter or an X-CSRF-Token header, and must match the CSRF token stored in the signed session cookie. It also checks that the request's `Referer` (if any) is the current host. See: * https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/security.html#checking-csrf-tokens-automatically * https://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html#pyramid.config.Configurator.set_default_csrf_options This is a safer default. The current implementation requires all views receiving form submissions to use a Colander schema that's a subclass of `CSRFSchema`. It's too easy to forget to add CSRF protection to a form if it doesn't use Colander (for example: perhaps there are no parameters to be validated) or if it has a schema that doesn't subclass `CSRFSchema`. Even if the view's schema *does* sublass `CSRFSchema`, if it wants to have a `validate()` method it must remember to call `super().validate()` or it'll disable `CSRFSchema`'s CSRF protection. This commit removes the CSRF protection code form `CSRFSchema` (that schema is now only used to *serialize* the CSRF tokens into the forms, but doesn't do any CSRF validation at *deserialization* time) and instead enables Pyramid's global CSRF protection option. CSRF protection can be disabled for individual views by passing `require_csrf=False` to `@view_config`. This has been added to h's custom `@api_config` decorator so that CSRF protection is disabled for all API endpoints.
1 parent 3afebfd commit 21e84de

File tree

14 files changed

+40
-84
lines changed

14 files changed

+40
-84
lines changed

Diff for: h/accounts/schemas.py

-4
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,6 @@ class EmailChangeSchema(CSRFSchema):
201201
password = password_node(title=_("Confirm password"), hide_until_form_active=True)
202202

203203
def validator(self, node, value):
204-
super().validator(node, value)
205204
exc = colander.Invalid(node)
206205
request = node.bindings["request"]
207206
svc = request.find_service(name="user_password")
@@ -229,7 +228,6 @@ class PasswordChangeSchema(CSRFSchema):
229228
)
230229

231230
def validator(self, node, value): # pragma: no cover
232-
super().validator(node, value)
233231
exc = colander.Invalid(node)
234232
request = node.bindings["request"]
235233
svc = request.find_service(name="user_password")
@@ -249,8 +247,6 @@ class DeleteAccountSchema(CSRFSchema):
249247
password = password_node(title=_("Confirm password"))
250248

251249
def validator(self, node, value):
252-
super().validator(node, value)
253-
254250
request = node.bindings["request"]
255251
svc = request.find_service(name="user_password")
256252

Diff for: h/app.py

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def create_app(_global_config, **settings): # pragma: no cover
3030

3131

3232
def includeme(config): # pragma: no cover
33+
config.set_default_csrf_options(require_csrf=True)
34+
3335
config.scan("h.subscribers")
3436

3537
config.add_tween("h.tweens.conditional_http_tween_factory", under=EXCVIEW)

Diff for: h/schemas/auth_client.py

-2
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ class CreateAuthClientSchema(CSRFSchema):
5757
)
5858

5959
def validator(self, node, value):
60-
super().validator(node, value)
61-
6260
grant_type = value.get("grant_type")
6361
redirect_url = value.get("redirect_url")
6462

Diff for: h/schemas/base.py

+34-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import deform
77
import jsonschema
88
from pyramid import httpexceptions
9-
from pyramid.csrf import check_csrf_token, get_csrf_token
9+
from pyramid.csrf import get_csrf_token
1010

1111

1212
@colander.deferred
@@ -21,23 +21,48 @@ class ValidationError(httpexceptions.HTTPBadRequest):
2121

2222
class CSRFSchema(colander.Schema):
2323
"""
24-
A CSRFSchema backward-compatible with the one from the hem module.
24+
Add a hidden CSRF token to forms when seralized using Deform.
2525
26-
Unlike hem, this doesn't require that the csrf_token appear in the
27-
serialized appstruct.
26+
This is intended as a base class for other schemas to inherit from if the
27+
schema's form needs a CSRF token (by default all form submissions do need a
28+
CSRF token).
29+
30+
This schema *does not* implement CSRF protection. That's enabled globally
31+
for non-GET requests by config.set_default_csrf_options(require_csrf=True).
2832
"""
2933

3034
csrf_token = colander.SchemaNode(
3135
colander.String(),
3236
widget=deform.widget.HiddenWidget(),
37+
# When serializing (i.e. when rendering a form) if there's no
38+
# csrf_token then call deferred_csrf_token() to get one.
3339
default=deferred_csrf_token,
34-
missing=None,
40+
# Allow data with no "csrf_token" field to be *deserialized* successfully
41+
# (the deserialized data will contain no "csrf_token" field.)
42+
#
43+
# CSRF protection isn't provided by this schema, it's provided by
44+
# Pyramid's config.set_default_csrf_options(require_csrf=True).
45+
#
46+
# Nonetheless, without a `missing` value, when deserializing any
47+
# subclass of this schema Colander would require a csrf_token field to
48+
# be present in the data (even if this schema doesn't actually check
49+
# that the token is valid).
50+
#
51+
# In production any request missing a CSRF token would be rejected by
52+
# Pyramid's CSRF protection before even reaching schema
53+
# deserialization. So by the time we get to schema deserialization
54+
# there must be a CSRF token and this `missing` value is seemingly
55+
# unnecessary.
56+
#
57+
# However:
58+
#
59+
# 1. The CSRF token may be in an X-CSRF-Token header rather than in a
60+
# POST param.
61+
# 2. Unittests for schemas often don't set a CSRF token and would fail
62+
# if this `missing` value wasn't here.
63+
missing=colander.drop,
3564
)
3665

37-
def validator(self, node, _value):
38-
request = node.bindings["request"]
39-
check_csrf_token(request)
40-
4166

4267
class JSONSchema:
4368
"""

Diff for: h/schemas/forms/accounts/forgot_password.py

-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ class ForgotPasswordSchema(CSRFSchema):
1717
)
1818

1919
def validator(self, node, value):
20-
super().validator(node, value)
21-
2220
request = node.bindings["request"]
2321
email = value.get("email")
2422
user = models.User.get_by_email(request.db, email, request.default_authority)

Diff for: h/schemas/forms/accounts/login.py

-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ class LoginSchema(CSRFSchema):
3636
)
3737

3838
def validator(self, node, value):
39-
super().validator(node, value)
40-
4139
request = node.bindings["request"]
4240
username = value.get("username")
4341
password = value.get("password")

Diff for: h/schemas/forms/admin/group.py

-1
Original file line numberDiff line numberDiff line change
@@ -219,5 +219,4 @@ def __init__(self, *args):
219219
)
220220

221221
def validator(self, node, value):
222-
super().validator(node, value)
223222
username_validator(node, value)

Diff for: h/views/api/auth.py

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def post(self):
104104
request_param="response_mode=web_message",
105105
is_authenticated=True,
106106
renderer="h:templates/oauth/authorize_web_message.html.jinja2",
107+
require_csrf=False,
107108
)
108109
def post_web_message(self):
109110
"""

Diff for: h/views/api/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def add_api_view( # noqa: PLR0913
6565
`route_name` must be specified.
6666
:param dict **settings: Arguments to pass on to ``config.add_view``
6767
"""
68+
settings.setdefault("require_csrf", False)
6869
settings.setdefault("renderer", "json")
6970
settings.setdefault("decorator", (cors_policy, version_media_type_header(subtype)))
7071

Diff for: tests/functional/h/views/admin/permissions_test.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@ def test_accessible_by_staff(self, app, url, accessible):
3535

3636
assert res.status_code == 200 if accessible else 404
3737

38-
GROUP_PAGES = (
39-
("POST", "/admin/groups/delete/{pubid}", 302),
40-
("GET", "/admin/groups/{pubid}", 200),
41-
)
38+
GROUP_PAGES = (("GET", "/admin/groups/{pubid}", 200),)
4239

4340
@pytest.mark.usefixtures("with_logged_in_admin")
4441
@pytest.mark.parametrize("method,url_template,success_code", GROUP_PAGES)

Diff for: tests/unit/h/accounts/schemas_test.py

+1-16
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import colander
55
import pytest
6-
from pyramid.exceptions import BadCSRFToken
76

87
from h.accounts import schemas
98
from h.services.user_password import UserPasswordService
@@ -156,9 +155,7 @@ def test_it_validates_with_valid_payload(
156155

157156
result = schema.deserialize(valid_params)
158157

159-
assert result == dict(
160-
valid_params, privacy_accepted=True, comms_opt_in=None, csrf_token=None
161-
)
158+
assert result == dict(valid_params, privacy_accepted=True, comms_opt_in=None)
162159

163160
@pytest.fixture
164161
def valid_params(self):
@@ -194,18 +191,6 @@ def test_it_is_valid_if_email_same_as_users_existing_email(
194191

195192
schema.deserialize({"email": user.email, "password": "flibble"})
196193

197-
def test_it_is_invalid_if_csrf_token_missing(self, pyramid_request, schema):
198-
del pyramid_request.headers["X-CSRF-Token"]
199-
200-
with pytest.raises(BadCSRFToken):
201-
schema.deserialize({"email": "[email protected]", "password": "flibble"})
202-
203-
def test_it_is_invalid_if_csrf_token_wrong(self, pyramid_request, schema):
204-
pyramid_request.headers["X-CSRF-Token"] = "WRONG"
205-
206-
with pytest.raises(BadCSRFToken):
207-
schema.deserialize({"email": "[email protected]", "password": "flibble"})
208-
209194
def test_it_is_invalid_if_password_wrong(self, schema, user_password_service):
210195
user_password_service.check_password.return_value = False
211196

Diff for: tests/unit/h/schemas/base_test.py

-24
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
import colander
55
import pytest
6-
from pyramid import csrf
7-
from pyramid.exceptions import BadCSRFToken
86

97
from h.schemas import ValidationError
108
from h.schemas.base import CSRFSchema, JSONSchema, enum_type
@@ -25,28 +23,6 @@ class ExampleJSONSchema(JSONSchema):
2523
}
2624

2725

28-
class TestCSRFSchema:
29-
def test_raises_badcsrf_with_bad_csrf(self, pyramid_request):
30-
schema = ExampleCSRFSchema().bind(request=pyramid_request)
31-
32-
with pytest.raises(BadCSRFToken):
33-
schema.deserialize({})
34-
35-
def test_ok_with_good_csrf(self, pyramid_request):
36-
csrf_token = csrf.get_csrf_token(pyramid_request)
37-
pyramid_request.POST["csrf_token"] = csrf_token
38-
schema = ExampleCSRFSchema().bind(request=pyramid_request)
39-
40-
# Does not raise
41-
schema.deserialize({})
42-
43-
def test_ok_with_good_csrf_from_header(self, pyramid_csrf_request):
44-
schema = ExampleCSRFSchema().bind(request=pyramid_csrf_request)
45-
46-
# Does not raise
47-
schema.deserialize({})
48-
49-
5026
class TestJSONSchema:
5127
def test_it_raises_for_unsupported_schema_versions(self):
5228
class BadSchema(JSONSchema):

Diff for: tests/unit/h/schemas/forms/accounts/login_test.py

-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import colander
44
import pytest
5-
from pyramid.exceptions import BadCSRFToken
65

76
from h.schemas.forms.accounts import LoginSchema
87
from h.services.user import UserNotActivated
@@ -46,12 +45,6 @@ def test_it_returns_user_when_valid(
4645

4746
assert result["user"] is user
4847

49-
def test_invalid_with_bad_csrf(self, pyramid_request):
50-
schema = LoginSchema().bind(request=pyramid_request)
51-
52-
with pytest.raises(BadCSRFToken):
53-
schema.deserialize({"username": "jeannie", "password": "cake"})
54-
5548
def test_invalid_with_inactive_user(self, pyramid_csrf_request, user_service):
5649
schema = LoginSchema().bind(request=pyramid_csrf_request)
5750
user_service.fetch_for_login.side_effect = UserNotActivated()

Diff for: tests/unit/h/schemas/forms/admin/group_test.py

-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import colander
55
import pytest
6-
from pyramid.exceptions import BadCSRFToken
76

87
from h.models.group import (
98
GROUP_DESCRIPTION_MAX_LENGTH,
@@ -18,18 +17,6 @@ class TestAdminGroupSchema:
1817
def test_it_allows_with_valid_data(self, group_data, bound_schema):
1918
bound_schema.deserialize(group_data)
2019

21-
def test_it_raises_if_csrf_token_missing(self, group_data, bound_schema):
22-
del bound_schema.bindings["request"].headers["X-CSRF-Token"]
23-
24-
with pytest.raises(BadCSRFToken):
25-
bound_schema.deserialize(group_data)
26-
27-
def test_it_raises_if_csrf_token_wrong(self, group_data, bound_schema):
28-
bound_schema.bindings["request"].headers["X-CSRF-Token"] = "foobar"
29-
30-
with pytest.raises(BadCSRFToken):
31-
bound_schema.deserialize(group_data)
32-
3320
def test_it_raises_if_name_too_short(self, group_data, bound_schema):
3421
too_short_name = "a" * (GROUP_NAME_MIN_LENGTH - 1)
3522
group_data["name"] = too_short_name

0 commit comments

Comments
 (0)