This commit is contained in:
Andreu Vallbona 2025-11-16 02:38:59 +02:00 committed by GitHub
commit 5fd7d7cc47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 130 additions and 28 deletions

View file

@ -57,6 +57,7 @@ class ASGIRequest(HttpRequest):
self.path_info = scope["path"].removeprefix(self.script_name)
else:
self.path_info = scope["path"]
self.path_info_is_empty = not bool(self.path_info)
# HTTP basics.
self.method = self.scope["method"].upper()
# Ensure query string is encoded correctly.

View file

@ -58,7 +58,16 @@ class WSGIRequest(HttpRequest):
script_name = get_script_name(environ)
# If PATH_INFO is empty (e.g. accessing the SCRIPT_NAME URL without a
# trailing slash), operate as if '/' was requested.
path_info = get_path_info(environ) or "/"
path_info = environ_path_info = get_path_info(environ)
if not path_info:
# Sometimes PATH_INFO exists, but is empty (e.g. accessing
# the SCRIPT_NAME URL without a trailing slash). We really need to
# operate as if they'd requested '/'. Not amazingly nice to force
# the path like this, but should be harmless.
self.path_info_is_empty = True
path_info = "/"
else:
self.path_info_is_empty = False
self.environ = environ
self.path_info = path_info
# be careful to only replace the first slash in the path because of
@ -66,7 +75,7 @@ class WSGIRequest(HttpRequest):
# stated in RFC 3986.
self.path = "%s/%s" % (script_name.rstrip("/"), path_info.replace("/", "", 1))
self.META = environ
self.META["PATH_INFO"] = path_info
self.META["PATH_INFO"] = environ_path_info
self.META["SCRIPT_NAME"] = script_name
self.method = environ["REQUEST_METHOD"].upper()
# Set content_type, content_params, and encoding.

View file

@ -70,6 +70,7 @@ class HttpRequest:
self.path = ""
self.path_info = ""
self.path_info_is_empty = True
self.method = None
self.resolver_match = None
self.content_type = None

View file

@ -46,28 +46,35 @@ class CommonMiddleware(MiddlewareMixin):
# Check for a redirect based on settings.PREPEND_WWW
host = request.get_host()
must_prepend = settings.PREPEND_WWW and host and not host.startswith("www.")
redirect_url = f"{request.scheme}://www.{host}" if must_prepend else ""
if settings.PREPEND_WWW and host and not host.startswith("www."):
# Check if we also need to append a slash so we can do it all
# with a single redirect. (This check may be somewhat expensive,
# so we only do it if we already know we're sending a redirect,
# or in process_response if we get a 404.)
if self.should_redirect_with_slash(request):
path = self.get_full_path_with_slash(request)
else:
path = request.get_full_path()
# Check if a slash should be appended to the URL
should_redirect_with_slash = self.should_redirect_with_slash(request)
return self.response_redirect_class(f"{request.scheme}://www.{host}{path}")
# If a slash should be appended, use the full path with a slash.
# Otherwise, just get the full path without forcing a slash.
if should_redirect_with_slash:
path = self.get_full_path_with_slash(request)
else:
path = request.get_full_path()
# If it's needed to redirect either based on settings.PREPEND_WWW
# or to append a slash, do so.
if redirect_url or should_redirect_with_slash:
redirect_url = f"{redirect_url}{path}"
return self.response_redirect_class(redirect_url)
def should_redirect_with_slash(self, request):
"""
Return True if settings.APPEND_SLASH is True and appending a slash to
the request path turns an invalid path into a valid one.
"""
if settings.APPEND_SLASH and not request.path_info.endswith("/"):
path_info = "" if request.path_info_is_empty else request.path_info
if settings.APPEND_SLASH and not path_info.endswith("/"):
urlconf = getattr(request, "urlconf", None)
if not is_valid_path(request.path_info, urlconf):
match = is_valid_path("%s/" % request.path_info, urlconf)
if not is_valid_path(path_info, urlconf):
match = is_valid_path("%s/" % path_info, urlconf)
if match:
view = match.func
return getattr(view, "should_append_slash", True)

View file

@ -93,6 +93,52 @@ class HandlerTests(SimpleTestCase):
# Expect "bad request" response
self.assertEqual(response.status_code, 400)
@override_settings(ROOT_URLCONF="handlers.urls")
def test_root_path_info_with_slash(self):
"""
If PATH_INFO is '/' and APPEND_SLASH is True, and a URL pattern
is defined for '^/$', then Django should render a response
from the corresponding view.
"""
environ = RequestFactory().get("/").environ
handler = WSGIHandler()
response = handler(environ, lambda *a, **k: None)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"Index")
@override_settings(ROOT_URLCONF="handlers.urls")
def test_root_path_info_without_slash(self):
"""
If PATH_INFO is empty and APPEND_SLASH is True, and a url pattern
is defined for '^/$' but not for '^$', then CommonMiddleware should
issue a redirect.
"""
environ = RequestFactory().get("").environ
handler = WSGIHandler()
response = handler(environ, lambda *a, **k: None)
self.assertEqual(response.status_code, 301)
self.assertEqual(response.url, "/")
@override_settings(APPEND_SLASH=False, ROOT_URLCONF="handlers.urls")
def test_root_path_info_nonempty_script_name_no_append_slash(self):
environ = RequestFactory().get("").environ
environ["SCRIPT_NAME"] = "site-root"
environ["PATH_INFO"] = ""
handler = WSGIHandler()
response = handler(environ, lambda *a, **k: None)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"Index")
@override_settings(APPEND_SLASH=True, ROOT_URLCONF="handlers.urls")
def test_root_path_info_nonempty_script_name_with_append_slash(self):
environ = RequestFactory().get("").environ
environ["SCRIPT_NAME"] = "site-root"
environ["PATH_INFO"] = ""
handler = WSGIHandler()
response = handler(environ, lambda *a, **k: None)
self.assertEqual(response.status_code, 301)
self.assertEqual(response.url, "site-root/")
@override_settings(ROOT_URLCONF="handlers.urls", MIDDLEWARE=[])
class TransactionsPerRequestTests(TransactionTestCase):

View file

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import path, re_path
from . import views
@ -18,4 +18,5 @@ urlpatterns = [
path("malformed_post/", views.malformed_post),
path("httpstatus_enum/", views.httpstatus_enum),
path("unawaited/", views.async_unawaited),
re_path("^$", views.index),
]

View file

@ -7,6 +7,10 @@ from django.http import HttpResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
def index(request):
return HttpResponse(b"Index")
def regular(request):
return HttpResponse(b"regular content")

View file

@ -171,15 +171,15 @@ class CommonMiddlewareTest(SimpleTestCase):
"""
# Use 4 slashes because of RequestFactory behavior.
request = self.rf.get("////evil.com/security")
r = CommonMiddleware(get_response_404).process_request(request)
self.assertIsNone(r)
res = CommonMiddleware(get_response_404).process_request(request)
self.assertIsNone(res)
response = HttpResponseNotFound()
r = CommonMiddleware(get_response_404).process_response(request, response)
self.assertEqual(r.status_code, 301)
self.assertEqual(r.url, "/%2Fevil.com/security/")
r = CommonMiddleware(get_response_404)(request)
self.assertEqual(r.status_code, 301)
self.assertEqual(r.url, "/%2Fevil.com/security/")
res = CommonMiddleware(get_response_404).process_response(request, response)
self.assertEqual(res.status_code, 301)
self.assertEqual(res.url, "/%2Fevil.com/security/")
res = CommonMiddleware(get_response_404)(request)
self.assertEqual(res.status_code, 301)
self.assertEqual(res.url, "/%2Fevil.com/security/")
@override_settings(APPEND_SLASH=False, PREPEND_WWW=True)
def test_prepend_www(self):
@ -320,6 +320,38 @@ class CommonMiddlewareTest(SimpleTestCase):
self.assertEqual(r.status_code, 301)
self.assertEqual(r.url, "http://www.testserver/customurlconf/slash/")
@override_settings(APPEND_SLASH=True)
def test_empty_path_info_not_found_with_append_slash(self):
req = HttpRequest()
req.urlconf = "middleware.urls"
res = HttpResponseNotFound()
middleware_res = CommonMiddleware(get_response_empty).process_response(req, res)
self.assertEqual(middleware_res.status_code, 301)
@override_settings(APPEND_SLASH=False)
def test_empty_path_info_not_found_without_append_slash(self):
req = HttpRequest()
req.urlconf = "middleware.urls"
res = HttpResponseNotFound()
middleware_res = CommonMiddleware(get_response_empty).process_response(req, res)
self.assertEqual(middleware_res.status_code, 404)
@override_settings(APPEND_SLASH=True)
def test_empty_path_info_200_with_append_slash(self):
req = HttpRequest()
req.urlconf = "middleware.urls"
res = HttpResponse("content")
middleware_res = CommonMiddleware(get_response_empty).process_response(req, res)
self.assertEqual(middleware_res.status_code, 200)
@override_settings(APPEND_SLASH=False)
def test_empty_path_info_200_without_append_slash(self):
req = HttpRequest()
req.urlconf = "middleware.urls"
res = HttpResponse("content")
middleware_res = CommonMiddleware(get_response_empty).process_response(req, res)
self.assertEqual(middleware_res.status_code, 200)
# Tests for the Content-Length header
def test_content_length_header_added(self):
@ -377,11 +409,11 @@ class CommonMiddlewareTest(SimpleTestCase):
"""Regression test for #15152"""
request = self.rf.get("/slash")
request.META["QUERY_STRING"] = "drink=café"
r = CommonMiddleware(get_response_empty).process_request(request)
self.assertIsNone(r)
res = CommonMiddleware(get_response_empty).process_request(request)
self.assertEqual(res.status_code, 301)
response = HttpResponseNotFound()
r = CommonMiddleware(get_response_empty).process_response(request, response)
self.assertEqual(r.status_code, 301)
res = CommonMiddleware(get_response_empty).process_response(request, response)
self.assertEqual(res.status_code, 301)
def test_response_redirect_class(self):
request = self.rf.get("/slash")

View file

@ -24,4 +24,5 @@ urlpatterns = [
path("csp-override-enforced/", views.csp_override_enforced),
path("csp-override-report-only/", views.csp_override_report_only),
path("csp-500/", views.csp_500),
re_path(r"^$", views.empty_view),
]