diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 4050c3aadd..87d2ced65a 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -5,6 +5,8 @@ Changelog
~~~~~~~~~~~~~~~~
* Add the ability to apply basic Page QuerySet optimizations to `specific()` sub-queries using `select_related` & `prefetch_related` (Andy Babic)
+ * Increase `DATA_UPLOAD_MAX_NUMBER_FIELDS` in project template (Matt Westcott)
+ * Stop invalid Site hostname records from breaking preview (Matt Westcott)
* 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/releases/6.4.md b/docs/releases/6.4.md
index 5a560d9f59..214f74e682 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -16,6 +16,7 @@ depth: 1
* Add the ability to apply basic Page QuerySet optimizations to `specific()` sub-queries using `select_related` & `prefetch_related`, see [](../reference/pages/queryset_reference.md) (Andy Babic)
* Increase `DATA_UPLOAD_MAX_NUMBER_FIELDS` in project template (Matt Westcott)
+ * Stop invalid Site hostname records from breaking preview (Matt Westcott)
### Bug fixes
diff --git a/wagtail/admin/tests/pages/test_preview.py b/wagtail/admin/tests/pages/test_preview.py
index dcdc5b46e1..b95f3f01ce 100644
--- a/wagtail/admin/tests/pages/test_preview.py
+++ b/wagtail/admin/tests/pages/test_preview.py
@@ -7,7 +7,7 @@ from django.utils import timezone
from freezegun import freeze_time
from wagtail.admin.views.pages.preview import PreviewOnEdit
-from wagtail.models import Page
+from wagtail.models import Page, Site
from wagtail.test.testapp.models import (
CustomPreviewSizesPage,
EventCategory,
@@ -228,6 +228,39 @@ class TestPreview(WagtailTestUtils, TestCase):
self.assertContains(response, "
Parties")
self.assertContains(response, "Holidays")
+ def test_preview_on_create_with_incorrect_site_hostname(self):
+ # Failing to set a valid hostname in the Site record (as determined by ALLOWED_HOSTS)
+ # should not prevent the preview from being generated.
+ Site.objects.filter(is_default_site=True).update(hostname="bad.example.com")
+
+ preview_url = reverse(
+ "wagtailadmin_pages:preview_on_add",
+ args=("tests", "eventpage", self.home_page.id),
+ )
+ response = self.client.post(preview_url, self.post_data)
+
+ # Check the JSON response
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ response.content.decode(),
+ {"is_valid": True, "is_available": True},
+ )
+
+ # Check the user can refresh the preview
+ preview_session_key = "wagtail-preview-tests-eventpage-{}".format(
+ self.home_page.id
+ )
+ self.assertIn(preview_session_key, self.client.session)
+
+ response = self.client.get(preview_url)
+
+ # Check the HTML response
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tests/event_page.html")
+ self.assertContains(response, "Beach party")
+ self.assertContains(response, "Parties")
+ self.assertContains(response, "Holidays")
+
def test_preview_on_edit_with_m2m_field(self):
preview_url = reverse(
"wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,)
@@ -254,6 +287,36 @@ class TestPreview(WagtailTestUtils, TestCase):
self.assertContains(response, "Parties")
self.assertContains(response, "Holidays")
+ def test_preview_on_edit_with_incorrect_site_hostname(self):
+ # Failing to set a valid hostname in the Site record (as determined by ALLOWED_HOSTS)
+ # should not prevent the preview from being generated.
+ Site.objects.filter(is_default_site=True).update(hostname="bad.example.com")
+
+ preview_url = reverse(
+ "wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,)
+ )
+ response = self.client.post(preview_url, self.post_data)
+
+ # Check the JSON response
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ response.content.decode(),
+ {"is_valid": True, "is_available": True},
+ )
+
+ # Check the user can refresh the preview
+ preview_session_key = f"wagtail-preview-{self.event_page.id}"
+ self.assertIn(preview_session_key, self.client.session)
+
+ response = self.client.get(preview_url)
+
+ # Check the HTML response
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tests/event_page.html")
+ self.assertContains(response, "Beach party")
+ self.assertContains(response, "Parties")
+ self.assertContains(response, "Holidays")
+
def test_preview_on_edit_with_valid_then_invalid_data(self):
preview_url = reverse(
"wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,)
diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py
index 952c4f7c68..0a93d9ad6c 100644
--- a/wagtail/models/__init__.py
+++ b/wagtail/models/__init__.py
@@ -41,6 +41,7 @@ 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.request import validate_host
from django.template.response import TemplateResponse
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
@@ -725,6 +726,26 @@ class PreviewableMixin:
handler.load_middleware()
return handler.get_response(request)
+ def _get_fallback_hostname(self):
+ """
+ Return a hostname that can be used on preview requests when the object has no
+ routable URL, or the real hostname is not valid according to ALLOWED_HOSTS.
+ """
+ try:
+ hostname = settings.ALLOWED_HOSTS[0]
+ except IndexError:
+ # Django disallows empty ALLOWED_HOSTS outright when DEBUG=False, so we must
+ # have DEBUG=True. In this mode Django allows localhost amongst others.
+ return "localhost"
+
+ if hostname == "*":
+ # Any hostname is allowed
+ return "localhost"
+
+ # Hostnames beginning with a dot are domain wildcards such as ".example.com" -
+ # these allow example.com itself, so just strip the dot
+ return hostname.lstrip(".")
+
def _get_dummy_headers(self, original_request=None):
"""
Return a dict of META information to be included in a faked HttpRequest object to pass to
@@ -734,20 +755,19 @@ class PreviewableMixin:
if url:
url_info = urlsplit(url)
hostname = url_info.hostname
+ if not validate_host(
+ hostname,
+ settings.ALLOWED_HOSTS or [".localhost", "127.0.0.1", "[::1]"],
+ ):
+ # The hostname is not valid according to ALLOWED_HOSTS - use a fallback
+ hostname = self._get_fallback_hostname()
+
path = url_info.path
port = url_info.port or (443 if url_info.scheme == "https" else 80)
scheme = url_info.scheme
else:
- # Cannot determine a URL to this object - cobble one together based on
- # whatever we find in ALLOWED_HOSTS
- try:
- hostname = settings.ALLOWED_HOSTS[0]
- if hostname == "*":
- # '*' is a valid value to find in ALLOWED_HOSTS[0], but it's not a valid domain name.
- # So we pretend it isn't there.
- raise IndexError
- except IndexError:
- hostname = "localhost"
+ # Cannot determine a URL to this object - cobble together an arbitrary valid one
+ hostname = self._get_fallback_hostname()
path = "/"
port = 80
scheme = "http"