diff --git a/src/admin/middleware.py b/src/admin/middleware.py index a0e15717..3e77dbc2 100644 --- a/src/admin/middleware.py +++ b/src/admin/middleware.py @@ -3,6 +3,7 @@ from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpRequest from django.utils.deprecation import MiddlewareMixin +import re class RedirectAdminMiddleware(MiddlewareMixin): @@ -17,3 +18,68 @@ def process_request(self, request: HttpRequest) -> HttpResponse: path = request.path[7:] # Remove '/admin/' return HttpResponseRedirect(f'{protocol}://{host}/{path}') return None + + +class TrustedHostMiddleware(MiddlewareMixin): + """ + Middleware to dynamically set the HTTP_HOST from a set of HTTP headers, + ensuring it matches the allowed hosts in settings.ALLOWED_HOSTS. + """ + + def __call__(self, request): + current_host = request.META.get('HTTP_HOST', '') + if (current_host and self.is_allowed_host(current_host)): + return self.get_response(request) + + headers_to_check = [ + ('HTTP_X_FORWARDED_HOST', 'X-Forwarded-Host'), + ('HTTP_HOST', 'Host'), + ('HTTP_ORIGIN', 'Origin'), + ('HTTP_REFERER', 'Referer') + ] + + host_name = None + for meta_key, header in headers_to_check: + # Prefer META if defined; fallback to request.headers + host_value = (request.META.get(meta_key) + or request.headers.get(header, '')) + # Extract the hostname, excluding protocol and path. + host_name = self.extract_hostname(host_value) + if host_name and self.is_allowed_host(host_name): + request.META['HTTP_HOST'] = host_name + break + host_name = None + + if not host_name: + # Fallback to using host from request URL if no matching header + fallback_host = (request.build_absolute_uri() + .split('//')[1].split('/')[0]) + fallback_host_name = self.extract_hostname(fallback_host) + if self.is_allowed_host(fallback_host_name): + request.META['HTTP_HOST'] = fallback_host_name + + return self.get_response(request) + + def extract_hostname(self, value): + """ + Extracts hostname from a URL, ignoring protocol, port, and path. + """ + # Regex captures hostname from start of string or after protocol. + # It stops at port delimiter ":" or path delimiter "/". + match = re.search(r'^(?:https?://)?([^:/]+)', value) + return match.group(1) if match else '' + + def is_allowed_host(self, host): + """ + Checks if a host matches any pattern in ALLOWED_HOSTS. + """ + # Loop through ALLOWED_HOSTS to see if the host matches any pattern. + # Patterns starting with '.' match any subdomain. + for allowed_host in settings.ALLOWED_HOSTS: + if allowed_host.startswith('.'): + domain = allowed_host[1:] + if host.endswith('.' + domain) or host == domain[1:]: + return True + elif host == allowed_host: # Exact match + return True + return False diff --git a/src/admin/urls.py b/src/admin/urls.py index 81ec68c7..6071e289 100644 --- a/src/admin/urls.py +++ b/src/admin/urls.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +from django.conf import settings from django.conf.urls import include, url from wagtail.admin import urls as wagtailadmin_urls @@ -10,7 +11,14 @@ urlpatterns = base_urlpatterns.copy() # Insert `wagtailadmin_urls` at appropriate level -urlpatterns.insert( - len(urlpatterns) - 3, - url(r'', include(wagtailadmin_urls)) -) +if settings.DEBUG: + # If DEBUG, two static file-urls are put at the end + urlpatterns.insert( + len(urlpatterns) - 3, + url(r'', include(wagtailadmin_urls)) + ) +else: + urlpatterns.insert( + len(urlpatterns) - 1, + url(r'', include(wagtailadmin_urls)) + ) diff --git a/src/moore/settings/base.py b/src/moore/settings/base.py index 27eb3945..efd822c0 100644 --- a/src/moore/settings/base.py +++ b/src/moore/settings/base.py @@ -80,6 +80,9 @@ ] MIDDLEWARE = [ + # Make sure the HTTP_HOST is set correctly – djang-hosts uses get_host() + # https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.HttpRequest.get_host + 'admin.middleware.TrustedHostMiddleware', # Subdomain for admin site. Needed by django_hosts 'django_hosts.middleware.HostsRequestMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -92,10 +95,10 @@ 'django.middleware.locale.LocaleMiddleware', 'wagtail.contrib.redirects.middleware.RedirectMiddleware', - # Subdomain for admin site. Needed by django_hosts - 'django_hosts.middleware.HostsResponseMiddleware', # Redirect GET html at path "/admin/*" admin subdomain 'admin.middleware.RedirectAdminMiddleware', + # Subdomain for admin site. Needed by django_hosts + 'django_hosts.middleware.HostsResponseMiddleware', ] DATABASES = { diff --git a/src/moore/urls.py b/src/moore/urls.py index 8034d236..fab843a0 100644 --- a/src/moore/urls.py +++ b/src/moore/urls.py @@ -10,7 +10,6 @@ from wagtail.admin import urls as wagtailadmin_urls from .api import api_router -from .urls_utils import delete_urls from members.views import member_check_api @@ -53,11 +52,3 @@ settings.MEDIA_URL, document_root=settings.MEDIA_ROOT ) - -# We remove the /admin redirect -# if running tests in order to make writing tests easier. -if settings.IS_RUNNING_TEST: - urlpatterns = delete_urls( - urlpatterns, - delete_name='wagtailadmin_redirect' - ) diff --git a/src/moore/urls_utils.py b/src/moore/urls_utils.py deleted file mode 100644 index 29ad482a..00000000 --- a/src/moore/urls_utils.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import absolute_import, unicode_literals - - -def delete_urls(urlpatterns: list, delete_name: str): - for index, pattern in enumerate(urlpatterns): - if hasattr(pattern, 'name'): - if pattern.name == delete_name: - # Insert before index - urlpatterns.pop(index) - break - - return urlpatterns