Skip to content

Commit 66a172a

Browse files
authored
feat: add domain redirect middleware (#3883)
* feat: add domain redirect middleware * test: reduce tests * test: reduce tests * test: reduce tests * fix: correct redirect with x-forwarded-host header * fix tests * review changes * more changes * fmt * update app.json * fmt * revert: add MITXPRO_BASE_URL configuration to app.json * Disable canonical hostname redirect by default
1 parent cdfddc9 commit 66a172a

5 files changed

Lines changed: 153 additions & 0 deletions

File tree

app.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
"description": "How long the blog should be cached",
4848
"required": false
4949
},
50+
"CANONICAL_HOSTNAME_REDIRECT_ENABLED": {
51+
"description": "Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname",
52+
"required": false
53+
},
5054
"CELERY_BROKER_URL": {
5155
"description": "Where celery should get tasks, default is Redis URL",
5256
"required": false

conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,9 @@ def django_db_setup(django_db_setup, django_db_blocker): # noqa: ARG001
9999
if not index_page_class.objects.filter(**index_page_content).exists():
100100
index_page = index_page_class(**index_page_content)
101101
home_page.add_child(instance=index_page)
102+
103+
104+
@pytest.fixture(autouse=True)
105+
def canonical_hostname_redirect_disabled_by_default(settings):
106+
"""Disable canonical hostname redirects in tests unless explicitly enabled."""
107+
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = False

mitxpro/middleware.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Middleware for MIT xPRO"""
2+
3+
from urllib.parse import urlparse
4+
5+
from django.conf import settings
6+
from django.http import HttpResponseRedirect
7+
8+
9+
class HostnameRedirectMiddleware:
10+
"""Middleware that redirects requests arriving at an incorrect hostname to the
11+
canonical hostname configured in SITE_BASE_URL."""
12+
13+
def __init__(self, get_response):
14+
self.get_response = get_response
15+
16+
def __call__(self, request):
17+
site_base_url = getattr(settings, "SITE_BASE_URL", None)
18+
if not site_base_url:
19+
return self.get_response(request)
20+
21+
parsed = urlparse(site_base_url)
22+
canonical_host = parsed.netloc
23+
canonical_scheme = parsed.scheme
24+
25+
if (
26+
not settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED
27+
or request.get_host() == canonical_host
28+
):
29+
return self.get_response(request)
30+
31+
redirect_url = "{}://{}{}".format(
32+
canonical_scheme, canonical_host, request.get_full_path()
33+
)
34+
return HttpResponseRedirect(redirect_url)

mitxpro/middleware_test.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for mitxpro middleware"""
2+
3+
import pytest
4+
from rest_framework import status
5+
6+
from mitxpro.middleware import HostnameRedirectMiddleware
7+
8+
9+
CANONICAL_URL = "https://xpro.mit.edu"
10+
CANONICAL_HOST = "xpro.mit.edu"
11+
WRONG_HOST = "xpro-web.odl.mit.edu"
12+
13+
14+
@pytest.fixture()
15+
def middleware(mocker):
16+
return HostnameRedirectMiddleware(get_response=mocker.Mock(return_value=None))
17+
18+
19+
@pytest.mark.parametrize(
20+
(
21+
"site_base_url",
22+
"server_name",
23+
"redirect_enabled",
24+
"expect_redirect",
25+
"expected_location",
26+
),
27+
[
28+
# Matching host -> passes through
29+
(CANONICAL_URL, CANONICAL_HOST, True, False, None),
30+
# Wrong host + redirect enabled -> redirect
31+
(CANONICAL_URL, WRONG_HOST, True, True, f"{CANONICAL_URL}/some/path/"),
32+
# Wrong host + redirect disabled -> passes through
33+
(CANONICAL_URL, WRONG_HOST, False, False, None),
34+
# No SITE_BASE_URL configured -> passes through
35+
(None, WRONG_HOST, True, False, None),
36+
],
37+
)
38+
def test_hostname_redirect_middleware(
39+
rf,
40+
settings,
41+
middleware,
42+
site_base_url,
43+
server_name,
44+
redirect_enabled,
45+
expect_redirect,
46+
expected_location,
47+
):
48+
"""Tests HostnameRedirectMiddleware redirects or passes through based on host and setting."""
49+
settings.SITE_BASE_URL = site_base_url
50+
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = redirect_enabled
51+
request = rf.get("/some/path/", SERVER_NAME=server_name)
52+
response = middleware(request)
53+
54+
if expect_redirect:
55+
assert response.status_code == status.HTTP_302_FOUND
56+
assert response["Location"] == expected_location
57+
else:
58+
middleware.get_response.assert_called_once_with(request)
59+
60+
61+
def test_redirect_preserves_query_string(rf, settings, middleware):
62+
"""The redirect preserves the full path including query string."""
63+
settings.SITE_BASE_URL = CANONICAL_URL
64+
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
65+
request = rf.get(
66+
"/some/path/", {"foo": "bar", "baz": "qux"}, SERVER_NAME=WRONG_HOST
67+
)
68+
response = middleware(request)
69+
assert response.status_code == status.HTTP_302_FOUND
70+
assert response["Location"].startswith(f"{CANONICAL_URL}/some/path/?")
71+
assert "foo=bar" in response["Location"]
72+
assert "baz=qux" in response["Location"]
73+
74+
75+
def test_api_path_bypasses_redirect_checks(rf, settings, middleware):
76+
"""API routes still follow the same redirect setting behavior."""
77+
settings.SITE_BASE_URL = CANONICAL_URL
78+
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
79+
request = rf.get("/api/v1/topics/", SERVER_NAME=WRONG_HOST)
80+
response = middleware(request)
81+
assert response.status_code == status.HTTP_302_FOUND
82+
assert response["Location"] == f"{CANONICAL_URL}/api/v1/topics/"
83+
84+
85+
def test_hostname_redirect_checks_actual_http_host(rf, settings, middleware):
86+
"""Redirect decision is based on actual HTTP_HOST, not X-Forwarded-Host.
87+
88+
This prevents infinite redirects when X-Forwarded-Host persists through
89+
redirects in proxy scenarios. The middleware checks the real incoming
90+
HTTP_HOST header, not Django's interpretation via request.get_host()
91+
which respects USE_X_FORWARDED_HOST.
92+
"""
93+
settings.SITE_BASE_URL = CANONICAL_URL
94+
settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True
95+
# Create request with wrong actual HTTP_HOST
96+
request = rf.get("/some/path/")
97+
# Manually set the actual HTTP_HOST to a non-canonical value
98+
request.META["HTTP_HOST"] = WRONG_HOST
99+
response = middleware(request)
100+
# Should redirect because actual HTTP_HOST doesn't match canonical
101+
assert response.status_code == 302
102+
assert response["Location"] == f"{CANONICAL_URL}/some/path/"

mitxpro/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204

205205
MIDDLEWARE = (
206206
"django.middleware.security.SecurityMiddleware",
207+
"mitxpro.middleware.HostnameRedirectMiddleware",
207208
"django.contrib.sessions.middleware.SessionMiddleware",
208209
"affiliate.middleware.AffiliateMiddleware",
209210
"oauth2_provider.middleware.OAuth2TokenMiddleware",
@@ -1516,3 +1517,9 @@
15161517
default=[],
15171518
description="Comma-separated list of email addresses to receive notifications about external data syncs",
15181519
)
1520+
1521+
CANONICAL_HOSTNAME_REDIRECT_ENABLED = get_bool(
1522+
name="CANONICAL_HOSTNAME_REDIRECT_ENABLED",
1523+
default=False,
1524+
description="Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname",
1525+
)

0 commit comments

Comments
 (0)