0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-21 18:09:02 +01:00

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
This commit is contained in:
Andy Babic 2024-11-20 00:15:36 +00:00 committed by GitHub
parent 3697ee1f2a
commit 66f1e817eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 151 additions and 1 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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 -

View File

@ -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)

View File

@ -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")

View File

@ -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