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:
parent
3697ee1f2a
commit
66f1e817eb
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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 -
|
||||
|
@ -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)
|
||||
|
||||
|
32
wagtail/tests/test_page_allowed_http_methods.py
Normal file
32
wagtail/tests/test_page_allowed_http_methods.py
Normal 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")
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user