-
Notifications
You must be signed in to change notification settings - Fork 439
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
Enable CSRF protection globally by default #9334
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot reviewed 5 out of 14 changed files in this pull request and generated no comments.
Files not reviewed (9)
- h/schemas/forms/accounts/forgot_password.py: Evaluated as low risk
- h/schemas/forms/accounts/login.py: Evaluated as low risk
- h/accounts/schemas.py: Evaluated as low risk
- tests/unit/h/schemas/forms/admin/group_test.py: Evaluated as low risk
- tests/unit/h/schemas/forms/accounts/login_test.py: Evaluated as low risk
- tests/unit/h/schemas/base_test.py: Evaluated as low risk
- h/schemas/forms/admin/group.py: Evaluated as low risk
- h/views/api/auth.py: Evaluated as low risk
- h/schemas/auth_client.py: Evaluated as low risk
d6a7000
to
21e84de
Compare
Cookie-authenticated API requests made by h's own frontend code (for example on the new group management pages) will still require a CSRF token. That's implemented separately by the diff --git a/h/views/groups.py b/h/views/groups.py
index a3de5c0f8..aa793a666 100644
--- a/h/views/groups.py
+++ b/h/views/groups.py
@@ -53,7 +53,7 @@ class GroupCreateEditController:
return {
"method": method,
"url": self.request.route_url(route_name, **kw),
- "headers": {"X-CSRF-Token": csrf_token},
+ "headers": {},
}
js_config = { |
@@ -201,7 +201,6 @@ class EmailChangeSchema(CSRFSchema): | |||
password = password_node(title=_("Confirm password"), hide_until_form_active=True) | |||
|
|||
def validator(self, node, value): | |||
super().validator(node, value) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're no longer inheriting from a CSRFSchema
superclass with a validate()
method, so several of these super()
calls had to be removed. It'll no longer be possible to accidentally disable CSRF protection by forgetting to call super()
here.
21e84de
to
78d79ed
Compare
h/schemas/base.py
Outdated
@@ -21,23 +21,49 @@ class ValidationError(httpexceptions.HTTPBadRequest): | |||
|
|||
class CSRFSchema(colander.Schema): | |||
""" | |||
A CSRFSchema backward-compatible with the one from the hem module. | |||
Add a hidden CSRF token to forms when seralized using Deform. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's still up to us to make sure that the CSRF token is included in form submissions, Pyramid doesn't do that automatically. In h's case the way that's done is via this Colander schema base class.
Colander schemas get serialized into HTML forms through the machinery of Deform.
There's probably a better way to do this by just putting the CSRF token field directly into our custom Deform templates and getting rid of this schema entirely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's probably a better way to do this by just putting the CSRF token field directly into our custom Deform templates and getting rid of this schema entirely.
Pushed a second commit to do this
@@ -104,6 +104,7 @@ def post(self): | |||
request_param="response_mode=web_message", | |||
is_authenticated=True, | |||
renderer="h:templates/oauth/authorize_web_message.html.jinja2", | |||
require_csrf=False, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FIXME: This is needed to get some functests passing. This line should be removed and the failing functests should be fixed to use a CSRF token. (This reveals a bug in the code: this view wasn't requiring CSRF when it should have been.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment here to indicate that this is being added only for the tests, and should not be required in actual usage.
GROUP_PAGES = ( | ||
("POST", "/admin/groups/delete/{pubid}", 302), | ||
("GET", "/admin/groups/{pubid}", 200), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FIXME: The POST
to /admin/groups/delete
is now failing because the test doesn't include a CSRF token. This actually reveals a bug in the code: that endpoint wasn't doing CSRF verification. Now it is. Rather than deleting the test case this functest should be fixed to use a CSRF token.
assert result == dict( | ||
valid_params, privacy_accepted=True, comms_opt_in=None, csrf_token=None | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test was expecting the Colander schema to include csrf_token: None
in the deserialized data if there was no csrf_token
in the input. I've changed it to omit csrf_token
from the output in this case.
h/schemas/base.py
Outdated
# POST param. | ||
# 2. Unittests for schemas often don't set a CSRF token and would fail | ||
# if this `missing` value wasn't here. | ||
missing=colander.drop, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note the change from missing=None
to missing=colander.drop
. This means that if there's no csrf_token
in the input data there'll be no csrf_token
in the output data (previously it was injecting "csrf_token": None
into the output). As noted in the code comment this case should never happen in production (unless someone is making requests with X-CSRF-Token
headers, but our pages don't do that), but it does happen in tests.
def test_it_is_invalid_if_csrf_token_missing(self, pyramid_request, schema): | ||
del pyramid_request.headers["X-CSRF-Token"] | ||
|
||
with pytest.raises(BadCSRFToken): | ||
schema.deserialize({"email": "[email protected]", "password": "flibble"}) | ||
|
||
def test_it_is_invalid_if_csrf_token_wrong(self, pyramid_request, schema): | ||
pyramid_request.headers["X-CSRF-Token"] = "WRONG" | ||
|
||
with pytest.raises(BadCSRFToken): | ||
schema.deserialize({"email": "[email protected]", "password": "flibble"}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Colander schemas no longer do CSRF verification. Several unittests like this can now be removed. (In most cases developers had forgotten to add CSRF test cases for their schemas anyway.)
Generating a developer token at http://localhost:5000/account/developer is failing for me on this branch. This change fixed it locally: diff --git a/h/templates/accounts/developer.html.jinja2 b/h/templates/accounts/developer.html.jinja2
index 761a3c7ed..f24c0c93c 100644
--- a/h/templates/accounts/developer.html.jinja2
+++ b/h/templates/accounts/developer.html.jinja2
@@ -20,6 +20,7 @@
{% endif %}
<form method="post" class="js-disable-on-submit">
+ <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% if token %}
<button type="submit" class="btn"
title="{% trans %}Delete your API token and generate a new one{% endtrans%}"> |
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.
There's no need for this base class. This commit just replaces it with a line in h/templates/deform/form.jinja2 that adds a CSRF token to every form.
2c9573c
to
d20457e
Compare
@@ -30,6 +30,8 @@ def create_app(_global_config, **settings): # pragma: no cover | |||
|
|||
|
|||
def includeme(config): # pragma: no cover | |||
config.set_default_csrf_options(require_csrf=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what we're doing instead of always subclassing a CSRFSchema
with a validate()
: we just ask Pyramid to do automatic CSRF verification on all requests with unsafe HTTP methods, this is opt-out rather than opt-in and our views and schemas don't need to be concerned with it at all.
context.setdefault( | ||
"get_csrf_token", partial(get_csrf_token, context["request"]) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding Pyramid's get_csrf_token()
method to the Jinja2 environment that's used to render our custom Deform templates, so that the base template for forms can automatically add a CSRF token.
TODO: There are lots of templates throughout the codebase that do their own get_csrf_token()
calls, it should be possible to remove these now. Actually they may always have been unnecessary? Not sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found a missing CSRF token in /h/templates/accounts/developer.html.jinja2 but otherwise this looks good.
@@ -104,6 +104,7 @@ def post(self): | |||
request_param="response_mode=web_message", | |||
is_authenticated=True, | |||
renderer="h:templates/oauth/authorize_web_message.html.jinja2", | |||
require_csrf=False, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment here to indicate that this is being added only for the tests, and should not be required in actual usage.
@@ -75,7 +82,7 @@ def create_form(request, *args, **kwargs): | |||
default) will use the renderer configured in the :py:mod:`h.form` module. | |||
""" | |||
env = request.registry[ENVIRONMENT_KEY] | |||
renderer = Jinja2Renderer(env, {"feature": request.feature}) | |||
renderer = Jinja2Renderer(env, {"feature": request.feature, "request": request}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing request
to the renderer so it can add it to the template context above.
@@ -8,6 +8,7 @@ | |||
class="form {{ field.css_class or '' }} | |||
{%- if field.use_inline_editing %} js-form {% endif %}"> | |||
<input type="hidden" name="__formid__" value="{{ field.formid }}" /> | |||
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The base Deform template for all forms now includes the CSRF token so this doesn't have to be done by each individual template.
Forms are rendered by having Deform serialize Colander templates to HTML using these custom Deform templates. When all our Colander templates were subclasses of a CSRFSchema
class with a csrf_token
field this causes Deform to include a CSRF token field in the serialized HTML (but only if the schema being serialized remembered to subclass CSRFSchema
). This replaces that, just automatically putting the CSRF token in every form regardless of schema.
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 ofGET
,HEAD
,OPTIONS
orTRACE
.The CSRF token must be in a
csrf_token
POST parameter or anX-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:
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 subclassCSRFSchema
. Even if the view's schema does sublassCSRFSchema
, if it wants to have avalidate()
method it must remember to callsuper().validate()
or it'll disableCSRFSchema
's CSRF protection.This commit removes the CSRF protection code from
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.