diff --git a/django/http/response.py b/django/http/response.py index 9bf0b14df5ef..45fb0177d1e3 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -632,13 +632,20 @@ def set_headers(self, filelike): class HttpResponseRedirectBase(HttpResponse): allowed_schemes = ["http", "https", "ftp"] - def __init__(self, redirect_to, preserve_request=False, *args, **kwargs): + def __init__( + self, + redirect_to, + preserve_request=False, + *args, + max_length=MAX_URL_REDIRECT_LENGTH, + **kwargs, + ): super().__init__(*args, **kwargs) self["Location"] = iri_to_uri(redirect_to) redirect_to_str = str(redirect_to) - if len(redirect_to_str) > MAX_URL_REDIRECT_LENGTH: + if max_length is not None and len(redirect_to_str) > max_length: raise DisallowedRedirect( - f"Unsafe redirect exceeding {MAX_URL_REDIRECT_LENGTH} characters" + f"Unsafe redirect exceeding {max_length} characters" ) parsed = urlsplit(redirect_to_str) if preserve_request: diff --git a/django/shortcuts.py b/django/shortcuts.py index 4eeb39121e97..84288a6e6f89 100644 --- a/django/shortcuts.py +++ b/django/shortcuts.py @@ -13,6 +13,7 @@ from django.template import loader from django.urls import NoReverseMatch, reverse from django.utils.functional import Promise +from django.utils.http import MAX_URL_REDIRECT_LENGTH from django.utils.translation import gettext as _ @@ -27,7 +28,14 @@ def render( return HttpResponse(content, content_type, status) -def redirect(to, *args, permanent=False, preserve_request=False, **kwargs): +def redirect( + to, + *args, + permanent=False, + preserve_request=False, + max_length=MAX_URL_REDIRECT_LENGTH, + **kwargs, +): """ Return an HttpResponseRedirect to the appropriate URL for the arguments passed. @@ -51,6 +59,7 @@ def redirect(to, *args, permanent=False, preserve_request=False, **kwargs): return redirect_class( resolve_url(to, *args, **kwargs), preserve_request=preserve_request, + max_length=max_length, ) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 15bb80067006..30eb99a7b2b9 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2643,9 +2643,8 @@ iterator` if you call its asynchronous version ``aiterator``. A ``QuerySet`` typically caches its results internally so that repeated evaluations do not result in additional queries. In contrast, ``iterator()`` -will read results directly, without doing any caching at the ``QuerySet`` level -(internally, the default iterator calls ``iterator()`` and caches the return -value). For a ``QuerySet`` which returns a large number of objects that you +will read results directly, without doing any caching at the ``QuerySet`` +level. For a ``QuerySet`` which returns a large number of objects that you only need to access once, this can result in better performance and a significant reduction in memory. diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index ba6041593ba4..906da679c7b0 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -1142,8 +1142,16 @@ types of HTTP responses. Like ``HttpResponse``, these subclasses live in that defaults to ``False``, producing a response with a 302 status code. If ``preserve_request`` is ``True``, the status code will be 307 instead. + The constructor accepts an optional ``max_length`` keyword argument to + override the maximum allowed length for the redirect URL. You can set it + to ``None`` to disable the length check. + See :class:`HttpResponse` for other optional constructor arguments. + .. versionchanged:: 6.1 + + ``max_length`` was added. + .. attribute:: HttpResponseRedirect.url This read-only attribute represents the URL the response will redirect diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 9003e01e1b71..2604f047c535 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -349,6 +349,10 @@ Requests and Responses * :attr:`HttpRequest.multipart_parser_class ` can now be customized to use a different multipart parser class. +* :class:`~django.http.HttpResponseRedirect` (and its subclasses), as well as + the :func:`~django.shortcuts.redirect` shortcut, now accept a ``max_length`` + parameter to override the default maximum URL length limit. + Security ~~~~~~~~ diff --git a/docs/topics/http/shortcuts.txt b/docs/topics/http/shortcuts.txt index b5ab1888cb63..5d14258cc1c2 100644 --- a/docs/topics/http/shortcuts.txt +++ b/docs/topics/http/shortcuts.txt @@ -91,7 +91,7 @@ This example is equivalent to:: ``redirect()`` ============== -.. function:: redirect(to, *args, permanent=False, preserve_request=False, **kwargs) +.. function:: redirect(to, *args, permanent=False, preserve_request=False, max_length=MAX_URL_REDIRECT_LENGTH, **kwargs) Returns an :class:`~django.http.HttpResponseRedirect` to the appropriate URL for the arguments passed. @@ -125,6 +125,14 @@ This example is equivalent to:: ``True`` ``True`` 308 ========= ================ ================ + An optional ``max_length`` keyword argument can be provided to override + the maximum allowed length for the redirect URL. Set it to ``None`` to + disable the length check. + + .. versionchanged:: 6.1 + + ``max_length`` was added. + Examples -------- diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py index 3e8364e616de..b990e9f81656 100644 --- a/tests/httpwrappers/tests.py +++ b/tests/httpwrappers/tests.py @@ -1,4 +1,5 @@ import copy +import itertools import json import os import pickle @@ -488,15 +489,24 @@ def test_stream_interface(self): def test_redirect_url_max_length(self): base_url = "https://example.com/" - for length in ( - MAX_URL_REDIRECT_LENGTH - 1, - MAX_URL_REDIRECT_LENGTH, + for length, response_class in itertools.product( + (MAX_URL_REDIRECT_LENGTH - 1, MAX_URL_REDIRECT_LENGTH), + (HttpResponseRedirect, HttpResponsePermanentRedirect), ): long_url = base_url + "x" * (length - len(base_url)) - with self.subTest(length=length): - response = HttpResponseRedirect(long_url) + with self.subTest(length=length, response_class=response_class): + response = response_class(long_url) self.assertEqual(response.url, long_url) - response = HttpResponsePermanentRedirect(long_url) + + def test_redirect_url_max_length_override_via_param(self): + base_url = "https://example.com/" + for (max_length, length), response_class in itertools.product( + ((None, MAX_URL_REDIRECT_LENGTH + 1), (100, 99), (100, 100)), + (HttpResponseRedirect, HttpResponsePermanentRedirect), + ): + long_url = base_url + "x" * (length - len(base_url)) + with self.subTest(length=length, response_class=response_class): + response = response_class(long_url, max_length=max_length) self.assertEqual(response.url, long_url) def test_unsafe_redirect(self): @@ -506,11 +516,23 @@ def test_unsafe_redirect(self): "file:///etc/passwd", "é" * (MAX_URL_REDIRECT_LENGTH + 1), ] - for url in bad_urls: - with self.assertRaises(DisallowedRedirect): - HttpResponseRedirect(url) - with self.assertRaises(DisallowedRedirect): - HttpResponsePermanentRedirect(url) + for url, response_class in itertools.product( + bad_urls, (HttpResponseRedirect, HttpResponsePermanentRedirect) + ): + with ( + self.subTest(url=url, response_class=response_class), + self.assertRaises(DisallowedRedirect), + ): + response_class(url) + + def test_unsafe_redirect_via_max_length(self): + url = "https://example.com/" + for response_class in (HttpResponseRedirect, HttpResponsePermanentRedirect): + with ( + self.subTest(response_class=response_class), + self.assertRaises(DisallowedRedirect), + ): + response_class(url, max_length=len(url) - 1) def test_header_deletion(self): r = HttpResponse("hello") diff --git a/tests/shortcuts/tests.py b/tests/shortcuts/tests.py index b80b8f595139..35942f846f28 100644 --- a/tests/shortcuts/tests.py +++ b/tests/shortcuts/tests.py @@ -1,7 +1,9 @@ +from django.core.exceptions import DisallowedRedirect from django.http.response import HttpResponseRedirectBase from django.shortcuts import redirect from django.test import SimpleTestCase, override_settings from django.test.utils import require_jinja2 +from django.utils.http import MAX_URL_REDIRECT_LENGTH @override_settings(ROOT_URLCONF="shortcuts.urls") @@ -56,3 +58,19 @@ def test_redirect_response_status_code(self): ) self.assertIsInstance(response, HttpResponseRedirectBase) self.assertEqual(response.status_code, expected_status_code) + + def test_redirect_max_length_default_raises(self): + long_url = "https://example.com/" + "x" * MAX_URL_REDIRECT_LENGTH + msg = f"Unsafe redirect exceeding {MAX_URL_REDIRECT_LENGTH} characters" + with self.assertRaisesMessage(DisallowedRedirect, msg): + redirect(long_url) + + def test_redirect_max_length_override_passes(self): + long_url = "https://example.com/" + "x" * MAX_URL_REDIRECT_LENGTH + response = redirect(long_url, max_length=None) + self.assertEqual(response.url, long_url) + + def test_redirect_custom_strict_limit_raises(self): + msg = "Unsafe redirect exceeding 5 characters" + with self.assertRaisesMessage(DisallowedRedirect, msg): + redirect("https://example.com/", max_length=5)