Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 0.194.1
---------------

- fix: Product and User CMS pages fixed (#3906)
- fix(pytest): move warning filters to pyproject and fix factory postgeneration warnings (#3905)
- chore(deps): update dependency granian to v2.7.4 [security] (#3904)
- feat: add domain redirect middleware (#3883)

Version 0.194.0 (Released May 07, 2026)
---------------

Expand Down
4 changes: 4 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"description": "How long the blog should be cached",
"required": false
},
"CANONICAL_HOSTNAME_REDIRECT_ENABLED": {
"description": "Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname",
"required": false
},
"CELERY_BROKER_URL": {
"description": "Where celery should get tasks, default is Redis URL",
"required": false
Expand Down
4 changes: 4 additions & 0 deletions cms/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class ProgramPageFactory(wagtail_factories.PageFactory):

class Meta:
model = ProgramPage
skip_postgeneration_save = True

@factory.post_generation
def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805
Expand Down Expand Up @@ -123,6 +124,7 @@ class CoursePageFactory(wagtail_factories.PageFactory):

class Meta:
model = CoursePage
skip_postgeneration_save = True

@factory.post_generation
def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805
Expand Down Expand Up @@ -153,6 +155,7 @@ class ExternalCoursePageFactory(wagtail_factories.PageFactory):

class Meta:
model = ExternalCoursePage
skip_postgeneration_save = True

@factory.post_generation
def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805
Expand Down Expand Up @@ -183,6 +186,7 @@ class ExternalProgramPageFactory(wagtail_factories.PageFactory):

class Meta:
model = ExternalProgramPage
skip_postgeneration_save = True

@factory.post_generation
def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805
Expand Down
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,9 @@ def django_db_setup(django_db_setup, django_db_blocker): # noqa: ARG001
if not index_page_class.objects.filter(**index_page_content).exists():
index_page = index_page_class(**index_page_content)
home_page.add_child(instance=index_page)


@pytest.fixture(autouse=True)
def canonical_hostname_redirect_disabled_by_default(settings):
"""Disable canonical hostname redirects in tests unless explicitly enabled."""
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = False
2 changes: 2 additions & 0 deletions courses/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class ProgramFactory(DjangoModelFactory):

class Meta:
model = Program
skip_postgeneration_save = True


class ProgramRunFactory(DjangoModelFactory):
Expand All @@ -95,6 +96,7 @@ class CourseFactory(DjangoModelFactory):

class Meta:
model = Course
skip_postgeneration_save = True

class Params:
no_program = factory.Trait(program=None, position_in_program=None)
Expand Down
1 change: 1 addition & 0 deletions ecommerce/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ def courses(self, create, extracted, **kwargs): # noqa: ARG002

class Meta:
model = models.DataConsentAgreement
skip_postgeneration_save = True


class DataConsentUserFactory(DjangoModelFactory):
Expand Down
2 changes: 1 addition & 1 deletion ecommerce/wagtail_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class ProductReadOnlyIndexView(AbstractReadOnlyIndexView):
"""

model = Product
queryset = Product.all_objects
queryset = Product.all_objects.all()


class ProductInspectView(InspectView):
Expand Down
34 changes: 34 additions & 0 deletions mitxpro/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Middleware for MIT xPRO"""

from urllib.parse import urlparse

from django.conf import settings
from django.http import HttpResponseRedirect


class HostnameRedirectMiddleware:
"""Middleware that redirects requests arriving at an incorrect hostname to the
canonical hostname configured in SITE_BASE_URL."""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
site_base_url = getattr(settings, "SITE_BASE_URL", None)
if not site_base_url:
return self.get_response(request)

parsed = urlparse(site_base_url)
canonical_host = parsed.netloc
canonical_scheme = parsed.scheme

if (
not settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED
or request.get_host() == canonical_host
):
return self.get_response(request)

redirect_url = "{}://{}{}".format(
canonical_scheme, canonical_host, request.get_full_path()
)
return HttpResponseRedirect(redirect_url)
102 changes: 102 additions & 0 deletions mitxpro/middleware_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Tests for mitxpro middleware"""

import pytest
from rest_framework import status

from mitxpro.middleware import HostnameRedirectMiddleware


CANONICAL_URL = "https://xpro.mit.edu"
CANONICAL_HOST = "xpro.mit.edu"
WRONG_HOST = "xpro-web.odl.mit.edu"


@pytest.fixture()
def middleware(mocker):
return HostnameRedirectMiddleware(get_response=mocker.Mock(return_value=None))


@pytest.mark.parametrize(
(
"site_base_url",
"server_name",
"redirect_enabled",
"expect_redirect",
"expected_location",
),
[
# Matching host -> passes through
(CANONICAL_URL, CANONICAL_HOST, True, False, None),
# Wrong host + redirect enabled -> redirect
(CANONICAL_URL, WRONG_HOST, True, True, f"{CANONICAL_URL}/some/path/"),
# Wrong host + redirect disabled -> passes through
(CANONICAL_URL, WRONG_HOST, False, False, None),
# No SITE_BASE_URL configured -> passes through
(None, WRONG_HOST, True, False, None),
],
)
def test_hostname_redirect_middleware(
rf,
settings,
middleware,
site_base_url,
server_name,
redirect_enabled,
expect_redirect,
expected_location,
):
"""Tests HostnameRedirectMiddleware redirects or passes through based on host and setting."""
settings.SITE_BASE_URL = site_base_url
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = redirect_enabled
request = rf.get("/some/path/", SERVER_NAME=server_name)
response = middleware(request)

if expect_redirect:
assert response.status_code == status.HTTP_302_FOUND
assert response["Location"] == expected_location
else:
middleware.get_response.assert_called_once_with(request)


def test_redirect_preserves_query_string(rf, settings, middleware):
"""The redirect preserves the full path including query string."""
settings.SITE_BASE_URL = CANONICAL_URL
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
request = rf.get(
"/some/path/", {"foo": "bar", "baz": "qux"}, SERVER_NAME=WRONG_HOST
)
response = middleware(request)
assert response.status_code == status.HTTP_302_FOUND
assert response["Location"].startswith(f"{CANONICAL_URL}/some/path/?")
assert "foo=bar" in response["Location"]
assert "baz=qux" in response["Location"]


def test_api_path_bypasses_redirect_checks(rf, settings, middleware):
"""API routes still follow the same redirect setting behavior."""
settings.SITE_BASE_URL = CANONICAL_URL
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
request = rf.get("/api/v1/topics/", SERVER_NAME=WRONG_HOST)
response = middleware(request)
assert response.status_code == status.HTTP_302_FOUND
assert response["Location"] == f"{CANONICAL_URL}/api/v1/topics/"


def test_hostname_redirect_checks_actual_http_host(rf, settings, middleware):
"""Redirect decision is based on actual HTTP_HOST, not X-Forwarded-Host.

This prevents infinite redirects when X-Forwarded-Host persists through
redirects in proxy scenarios. The middleware checks the real incoming
HTTP_HOST header, not Django's interpretation via request.get_host()
which respects USE_X_FORWARDED_HOST.
"""
settings.SITE_BASE_URL = CANONICAL_URL
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
# Create request with wrong actual HTTP_HOST
request = rf.get("/some/path/")
# Manually set the actual HTTP_HOST to a non-canonical value
request.META["HTTP_HOST"] = WRONG_HOST
response = middleware(request)
# Should redirect because actual HTTP_HOST doesn't match canonical
assert response.status_code == 302
assert response["Location"] == f"{CANONICAL_URL}/some/path/"
11 changes: 9 additions & 2 deletions mitxpro/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from mitxpro.celery_utils import OffsettingSchedule
from mitxpro.sentry import init_sentry

VERSION = "0.194.0"
VERSION = "0.194.1"

env.reset()

Expand Down Expand Up @@ -152,7 +152,7 @@
"wagtail.contrib.routable_page",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"users.apps.MitxproWagtailUsersAppConfig",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
Expand Down Expand Up @@ -204,6 +204,7 @@

MIDDLEWARE = (
"django.middleware.security.SecurityMiddleware",
"mitxpro.middleware.HostnameRedirectMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"affiliate.middleware.AffiliateMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware",
Expand Down Expand Up @@ -1516,3 +1517,9 @@
default=[],
description="Comma-separated list of email addresses to receive notifications about external data syncs",
)

CANONICAL_HOSTNAME_REDIRECT_ENABLED = get_bool(
name="CANONICAL_HOSTNAME_REDIRECT_ENABLED",
default=False,
description="Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname",
)
58 changes: 58 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,61 @@ no-binary-package = ["lxml", "xmlsec"]
[build-system]
requires = ["uv_build>=0.11.6,<0.12.0"]
build-backend = "uv_build"

[tool.pytest.ini_options]
addopts = "--cov . --cov-report term --cov-report html --cov-report xml --ds=mitxpro.settings --reuse-db"
norecursedirs = [
"node_modules",
".git",
".tox",
"static",
"templates",
".*",
"CVS",
"_darcs",
"{arch}",
"*.egg",
]
filterwarnings = [
"error",
# sentry-sdk deprecates configure_scope ahead of a future major release.
"ignore:sentry_sdk\\.configure_scope is deprecated and will be removed in the next major version.*:DeprecationWarning",
# Paramiko emits this warning when the test environment has no SSH host keys configured.
"ignore:Failed to load HostKeys",
# pytest-cov warns when coverage is disabled for targeted local runs.
"ignore:Coverage disabled via --no-cov switch!",
# Wagtail triggers Django 6.0 URLField scheme deprecations during test collection.
"ignore:The default scheme will be changed from 'http' to 'https' in Django 6\\.0.*:django.utils.deprecation.RemovedInDjango60Warning",
# pytest may import namespace-package directories while collecting tests.
"ignore:.*Not importing directory.*:ImportWarning",
]
env = [
"CELERY_TASK_ALWAYS_EAGER=True",
"DJANGO_SETTINGS_MODULE=mitxpro.settings",
"CYBERSOURCE_WSDL_URL=",
"CYBERSOURCE_MERCHANT_ID=",
"CYBERSOURCE_TRANSACTION_KEY=",
"CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY=",
"MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL=http://localhost:5000/",
"MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET=test-hmac-secret", # pragma: allowlist secret
"MITXPRO_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend",
"MITXPRO_NOTIFICATION_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend",
"RECAPTCHA_SITE_KEY=",
"RECAPTCHA_SECRET_KEY=",
"DEBUG=False",
"WEBPACK_DISABLE_LOADER_STATS=True",
"MAILGUN_KEY=fake_mailgun_key",
"MAILGUN_SENDER_DOMAIN=other.fake.site",
"MITXPRO_ADMIN_EMAIL=example@localhost",
"MITXPRO_BASE_URL=http://localhost:8053",
"MITXPRO_DB_DISABLE_SSL=True",
"MITXPRO_SECURE_SSL_REDIRECT=False",
"MITXPRO_USE_S3=False",
"OPENEDX_API_BASE_URL=http://localhost:18000",
"OPENEDX_API_CLIENT_ID=fake_client_id",
"OPENEDX_API_CLIENT_SECRET=fake_client_secret", # pragma: allowlist secret
"SENTRY_DSN=",
"WAGTAIL_CACHE_BACKEND=django.core.cache.backends.dummy.DummyCache",
"WAGTAIL_CACHE_URL=",
"POSTHOG_ENABLED=True",
]
38 changes: 0 additions & 38 deletions pytest.ini

This file was deleted.

12 changes: 12 additions & 0 deletions users/apps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
"""Users application"""

from django.apps import AppConfig
from wagtail.users.apps import WagtailUsersAppConfig


class UsersConfig(AppConfig):
"""Config for users app"""

name = "users"


class MitxproWagtailUsersAppConfig(WagtailUsersAppConfig):
"""
Custom WagtailUsersAppConfig for the mitxpro User model.

The viewset string is resolved lazily by Wagtail after the app registry is
ready, so no Django model imports are needed here.
"""

user_viewset = "users.wagtail_views.UserViewSet"
Loading
Loading