From 66f1e817eb7d218d1ae9a43f03b2a0f37cd37d4a Mon Sep 17 00:00:00 2001 From: Andy Babic Date: Wed, 20 Nov 2024 00:15:36 +0000 Subject: [PATCH] Allow page types to easily restrict what type of requests they respond to (#12473) * Allow page types to specify the request methods they support and block unsupported requests in serve() * Use 'before_serve_page' hook to serve OPTIONS responses * Add checks to RoutablePageMixin.serve() where the parent implementation is bypassed * Rename check_http_method to check_request_method and actually use the return value * Support Python 3.9 through to current approaches for `http` method strings * Include documentation, docstrings & changelog entry --- CHANGELOG.txt | 1 + docs/reference/models.md | 22 ++++++++ docs/releases/6.4.md | 1 + wagtail/compat.py | 16 ++++++ wagtail/models/__init__.py | 55 ++++++++++++++++++- wagtail/test/testapp/models.py | 4 ++ .../tests/test_page_allowed_http_methods.py | 32 +++++++++++ wagtail/wagtail_hooks.py | 21 +++++++ 8 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 wagtail/tests/test_page_allowed_http_methods.py diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f0d54678e0..d73e9511be 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -9,6 +9,7 @@ Changelog * Stop invalid Site hostname records from breaking preview (Matt Westcott) * Set sensible defaults for InlinePanel heading and label (Matt Westcott) * Limit tags autocompletion to 10 items to avoid performance issues with large number of matching tags (Aayushman Singh) + * Add the ability to restrict what types of requests a Pages supports via `allowed_http_methods` (Andy Babic) * Fix: Improve handling of translations for bulk page action confirmation messages (Matt Westcott) * Fix: Ensure custom rich text feature icons are correctly handled when provided as a list of SVG paths (Temidayo Azeez, Joel William, LB (Ben) Johnston) * Fix: Ensure manual edits to `StreamField` values do not throw an error (Stefan Hammer) diff --git a/docs/reference/models.md b/docs/reference/models.md index a85b0474af..cd852af34b 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -200,6 +200,28 @@ See also [django-treebeard](inv:treebeard:std:doc#index)'s [node API](inv:treebe .. automethod:: get_admin_display_title + .. autoattribute:: allowed_http_methods + + When customizing this attribute, developers are encouraged to use values from Python's built-in ``http.HTTPMethod`` enum in the list, as it is more robust, and makes use of values that already exist in memory. For example: + + .. code-block:: python + + from http import HTTPMethod + + class MyPage(Page): + allowed_http_methods = [HTTPMethod.GET, HTTPMethod.OPTIONS] + + The ``http.HTTPMethod`` enum wasn't added until Python 3.11, so if your project uses an older version of Python, you can use uppercase strings instead. For example: + + .. code-block:: python + + class MyPage(Page): + allowed_http_methods = ["GET", "OPTIONS"] + + .. automethod:: check_request_method + + .. automethod:: handle_options_request + .. autoattribute:: preview_modes .. autoattribute:: default_preview_mode diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md index 57cbb39520..c24be86591 100644 --- a/docs/releases/6.4.md +++ b/docs/releases/6.4.md @@ -19,6 +19,7 @@ depth: 1 * Stop invalid Site hostname records from breaking preview (Matt Westcott) * Set sensible defaults for InlinePanel heading and label (Matt Westcott) * Limit tags autocompletion to 10 items to avoid performance issues with large number of matching tags (Aayushman Singh) + * Add the ability to restrict what types of requests a Pages supports via [`allowed_http_methods`](#wagtail.models.Page.allowed_http_methods) (Andy Babic) ### Bug fixes diff --git a/wagtail/compat.py b/wagtail/compat.py index 6186c0f828..afc2bfdd15 100644 --- a/wagtail/compat.py +++ b/wagtail/compat.py @@ -11,3 +11,19 @@ except ValueError: raise ImproperlyConfigured( "AUTH_USER_MODEL must be of the form" " 'app_label.model_name'" ) + + +try: + from http import HTTPMethod +except ImportError: + # For Python < 3.11 + from enum import Enum + + class HTTPMethod(Enum): + GET = "GET" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py index 0a93d9ad6c..ad1f3051bf 100644 --- a/wagtail/models/__init__.py +++ b/wagtail/models/__init__.py @@ -40,7 +40,7 @@ from django.db.models import Q, Value from django.db.models.expressions import OuterRef, Subquery from django.db.models.functions import Concat, Substr from django.dispatch import receiver -from django.http import Http404 +from django.http import Http404, HttpResponse, HttpResponseNotAllowed from django.http.request import validate_host from django.template.response import TemplateResponse from django.urls import NoReverseMatch, reverse @@ -69,6 +69,7 @@ from wagtail.actions.publish_page_revision import PublishPageRevisionAction from wagtail.actions.publish_revision import PublishRevisionAction from wagtail.actions.unpublish import UnpublishAction from wagtail.actions.unpublish_page import UnpublishPageAction +from wagtail.compat import HTTPMethod from wagtail.coreutils import ( WAGTAIL_APPEND_SLASH, camelcase_to_underscore, @@ -1413,6 +1414,19 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): # Privacy options for page private_page_options = ["password", "groups", "login"] + # Allows page types to specify a list of HTTP method names that page instances will + # respond to. When the request type doesn't match, Wagtail should return a response + # with a status code of 405. + allowed_http_methods = [ + HTTPMethod.DELETE, + HTTPMethod.GET, + HTTPMethod.HEAD, + HTTPMethod.OPTIONS, + HTTPMethod.PATCH, + HTTPMethod.POST, + HTTPMethod.PUT, + ] + @staticmethod def route_for_request(request: HttpRequest, path: str) -> RouteResult | None: """ @@ -1450,6 +1464,13 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): if result is not None: return result[0] + @classmethod + def allowed_http_method_names(cls): + return [ + method.value if hasattr(method, "value") else method + for method in cls.allowed_http_methods + ] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.id: @@ -2115,6 +2136,38 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): self.get_context(request, *args, **kwargs), ) + def check_request_method(self, request, *args, **kwargs): + """ + Checks the ``method`` attribute of the request against those supported + by the page (as defined by :attr:`allowed_http_methods`) and responds + accordingly. + + If supported, ``None`` is returned, and the request is processed + normally. If not, a warning is logged and an ``HttpResponseNotAllowed`` + is returned, and any further request handling is terminated. + """ + allowed_methods = self.allowed_http_method_names() + if request.method not in allowed_methods: + logger.warning( + "Method Not Allowed (%s): %s", + request.method, + request.path, + extra={"status_code": 405, "request": request}, + ) + return HttpResponseNotAllowed(allowed_methods) + + def handle_options_request(self, request, *args, **kwargs): + """ + Returns an ``HttpResponse`` with an ``"Allow"`` header containing the list of + supported HTTP methods for this page. This method is used instead of + :meth:`serve` to handle requests when the OPTIONS HTTP verb is detected (and + ``http.HTTPMethod.OPTIONS`` is present in :attr:`allowed_http_methods` + for this type of page). + """ + return HttpResponse( + headers={"Allow": ", ".join(self.allowed_http_method_names())} + ) + def is_navigable(self): """ Return true if it's meaningful to browse subpages of this page - diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py index ab85347d7a..f5c276629f 100644 --- a/wagtail/test/testapp/models.py +++ b/wagtail/test/testapp/models.py @@ -44,6 +44,7 @@ from wagtail.blocks import ( StreamBlock, StructBlock, ) +from wagtail.compat import HTTPMethod from wagtail.contrib.forms.forms import FormBuilder, WagtailAdminFormPageForm from wagtail.contrib.forms.models import ( FORM_FIELD_CHOICES, @@ -527,6 +528,9 @@ class EventIndex(Page): intro = RichTextField(blank=True, max_length=50) ajax_template = "tests/includes/event_listing.html" + # NOTE: Using a mix of enum and string values to test handling of both + allowed_http_methods = [HTTPMethod.GET, "OPTIONS"] + def get_events(self): return self.get_children().live().type(EventPage) diff --git a/wagtail/tests/test_page_allowed_http_methods.py b/wagtail/tests/test_page_allowed_http_methods.py new file mode 100644 index 0000000000..f669fa44cc --- /dev/null +++ b/wagtail/tests/test_page_allowed_http_methods.py @@ -0,0 +1,32 @@ +from django.test import TestCase + +from wagtail.models import Page, Site +from wagtail.test.testapp.models import EventIndex + + +class AllowedHttpMethodsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + site = Site.objects.select_related("root_page").get(is_default_site=True) + cls.page = Page(title="Page", slug="page") + site.root_page.add_child(instance=cls.page) + cls.event_index_page = EventIndex(title="Event index", slug="event-index") + site.root_page.add_child(instance=cls.event_index_page) + + def test_options_request_for_default_page(self): + response = self.client.options(self.page.url) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["Allow"], "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT" + ) + + def test_options_request_for_restricted_page(self): + response = self.client.options(self.event_index_page.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Allow"], "GET, OPTIONS") + + def test_invalid_request_method_for_restricted_page(self): + response = self.client.post(self.event_index_page.url) + self.assertEqual(response.status_code, 405) + self.assertEqual(response["Allow"], "GET, OPTIONS") diff --git a/wagtail/wagtail_hooks.py b/wagtail/wagtail_hooks.py index 044d89ea71..93fbee070c 100644 --- a/wagtail/wagtail_hooks.py +++ b/wagtail/wagtail_hooks.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.auth.views import redirect_to_login @@ -8,12 +10,16 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext from wagtail import hooks +from wagtail.compat import HTTPMethod from wagtail.coreutils import get_content_languages from wagtail.log_actions import LogFormatter from wagtail.models import ModelLogEntry, Page, PageLogEntry, PageViewRestriction from wagtail.rich_text.pages import PageLinkHandler from wagtail.utils.timestamps import parse_datetime_localized, render_timestamp +if TYPE_CHECKING: + from django.http import HttpRequest + def require_wagtail_login(next): login_url = getattr( @@ -551,3 +557,18 @@ def register_workflow_log_actions(actions): } except (KeyError, TypeError): return _("Workflow cancelled") + + +@hooks.register("before_serve_page", order=0) +def check_request_method(page: Page, request: "HttpRequest", *args, **kwargs): + """ + Before serving, check the request method is permitted by the page, + and use the page object's :meth:``wagtail.models.Page.handle_options_request`` + method to generate a response if the OPTIONS HTTP verb is used. + """ + check_response = page.check_request_method(request, *args, **kwargs) + if check_response is not None: + return check_response + if request.method == HTTPMethod.OPTIONS.value: + return page.handle_options_request(request, *args, **kwargs) + return None