From b1fd24ac9f9035464af0a8ce92391c166a783253 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 15:39:52 -0700 Subject: [PATCH] skip_csrf(datasette, scope) plugin hook, refs #1377 --- datasette/app.py | 3 +++ datasette/hookspecs.py | 5 +++++ docs/internals.rst | 2 ++ docs/plugin_hooks.rst | 25 +++++++++++++++++++++++++ setup.py | 2 +- tests/fixtures.py | 2 ++ tests/plugins/my_plugin.py | 5 +++++ tests/test_plugins.py | 25 +++++++++++++++++++++++++ 8 files changed, 68 insertions(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index ce59ef54..e11c12eb 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1052,6 +1052,9 @@ class Datasette: DatasetteRouter(self, routes), signing_secret=self._secret, cookie_name="ds_csrftoken", + skip_if_scope=lambda scope: any( + pm.hook.skip_csrf(datasette=self, scope=scope) + ), ) if self.setting("trace_debug"): asgi = AsgiTracer(asgi) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 579787a2..63b06097 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -112,3 +112,8 @@ def table_actions(datasette, actor, database, table, request): @hookspec def database_actions(datasette, actor, database, request): """Links for the database actions menu""" + + +@hookspec +def skip_csrf(datasette, scope): + """Mechanism for skipping CSRF checks for certain requests""" diff --git a/docs/internals.rst b/docs/internals.rst index 72c86083..98df998a 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -778,6 +778,8 @@ If your plugin implements a ``
`` anywhere you will need to i +You can selectively disable CSRF protection using the :ref:`plugin_hook_skip_csrf` hook. + .. _internals_internal: The _internal database diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 8b2a691a..5af601b4 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1104,3 +1104,28 @@ database_actions(datasette, actor, database, request) The current HTTP :ref:`internals_request`. This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. + +.. _plugin_hook_skip_csrf: + +skip_csrf(datasette, scope) +--------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``scope`` - dictionary + The `ASGI scope `__ for the incoming HTTP request. + +This hook can be used to skip :ref:`internals_csrf` for a specific incoming request. For example, you might have a custom path at ``/submit-comment`` which is designed to accept comments from anywhere, whether or not the incoming request originated on the site and has an accompanying CSRF token. + +This example will disable CSRF protection for that specific URL path: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def skip_csrf(scope): + return scope["path"] == "/submit-comment" + +If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. diff --git a/setup.py b/setup.py index 4f095f29..8a651d32 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ setup( "uvicorn~=0.11", "aiofiles>=0.4,<0.8", "janus>=0.4,<0.7", - "asgi-csrf>=0.6", + "asgi-csrf>=0.9", "PyYAML~=5.3", "mergedeep>=1.1.1,<1.4.0", "itsdangerous>=1.1,<3.0", diff --git a/tests/fixtures.py b/tests/fixtures.py index cdd2e987..a79fc246 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -52,6 +52,7 @@ EXPECTED_PLUGINS = [ "register_magic_parameters", "register_routes", "render_cell", + "skip_csrf", "startup", "table_actions", ], @@ -152,6 +153,7 @@ def make_app_client( static_mounts=static_mounts, template_dir=template_dir, crossdb=crossdb, + pdb=True, ) ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n)))) yield TestClient(ds) diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 85a7467d..0e625623 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -348,3 +348,8 @@ def database_actions(datasette, database, actor, request): "label": label, } ] + + +@hookimpl +def skip_csrf(scope): + return scope["path"] == "/skip-csrf" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b3561dd5..14273282 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -825,3 +825,28 @@ def test_hook_database_actions(app_client): assert get_table_actions_links(response_2.text) == [ {"label": "Database: fixtures - BOB", "href": "/"}, ] + + +def test_hook_skip_csrf(app_client): + cookie = app_client.actor_cookie({"id": "test"}) + csrf_response = app_client.post( + "/post/", + post_data={"this is": "post data"}, + csrftoken_from=True, + cookies={"ds_actor": cookie}, + ) + assert csrf_response.status == 200 + missing_csrf_response = app_client.post( + "/post/", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert missing_csrf_response.status == 403 + # But "/skip-csrf" should allow + allow_csrf_response = app_client.post( + "/skip-csrf", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert allow_csrf_response.status == 405 # Method not allowed + # /skip-csrf-2 should not + second_missing_csrf_response = app_client.post( + "/skip-csrf-2", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert second_missing_csrf_response.status == 403