From a8cf8c292cfee98fe6cc873ca5221935f1d02271 Mon Sep 17 00:00:00 2001 From: varunkasyap Date: Wed, 26 Nov 2025 14:28:24 -0300 Subject: [PATCH 1/4] Fixed #36743 -- Increased URL max length enforced in HttpResponseRedirectBase. Refs CVE-2025-64458. The previous limit of 2048 characters reused the URLValidator constant and proved too restrictive for legitimate redirects to some third-party services. This change introduces a separate `MAX_URL_REDIRECT_LENGTH` constant (defaulting to 16384) and uses it in HttpResponseRedirectBase. Thanks Jacob Walls for report and review. --- django/http/response.py | 10 +++++++--- django/utils/http.py | 1 + docs/releases/5.2.9.txt | 5 +++++ tests/httpwrappers/tests.py | 17 +++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/django/http/response.py b/django/http/response.py index 020b2fcf3ab8..9bf0b14df5ef 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -22,7 +22,11 @@ from django.utils.datastructures import CaseInsensitiveMapping from django.utils.encoding import iri_to_uri from django.utils.functional import cached_property -from django.utils.http import MAX_URL_LENGTH, content_disposition_header, http_date +from django.utils.http import ( + MAX_URL_REDIRECT_LENGTH, + content_disposition_header, + http_date, +) from django.utils.regex_helper import _lazy_re_compile _charset_from_content_type_re = _lazy_re_compile( @@ -632,9 +636,9 @@ def __init__(self, redirect_to, preserve_request=False, *args, **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_LENGTH: + if len(redirect_to_str) > MAX_URL_REDIRECT_LENGTH: raise DisallowedRedirect( - f"Unsafe redirect exceeding {MAX_URL_LENGTH} characters" + f"Unsafe redirect exceeding {MAX_URL_REDIRECT_LENGTH} characters" ) parsed = urlsplit(redirect_to_str) if preserve_request: diff --git a/django/utils/http.py b/django/utils/http.py index 21d5822bf291..2950f3e69500 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -39,6 +39,7 @@ RFC3986_GENDELIMS = ":/?#[]@" RFC3986_SUBDELIMS = "!$&'()*+,;=" MAX_URL_LENGTH = 2048 +MAX_URL_REDIRECT_LENGTH = 16384 def urlencode(query, doseq=False): diff --git a/docs/releases/5.2.9.txt b/docs/releases/5.2.9.txt index edd82271d99e..8a8000a9f112 100644 --- a/docs/releases/5.2.9.txt +++ b/docs/releases/5.2.9.txt @@ -21,3 +21,8 @@ Bugfixes * Fixed a regression in Django 5.2.2 that caused a crash when using aggregate functions with an empty ``Q`` filter over a queryset with annotations (:ticket:`36751`). + +* Fixed a regression in Django 5.2.8 where ``DisallowedRedirect`` was raised by + :class:`~django.http.HttpResponseRedirect` and + :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048 + characters. The limit is now 16384 characters (:ticket:`36743`). diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py index 804bec50b08b..3e8364e616de 100644 --- a/tests/httpwrappers/tests.py +++ b/tests/httpwrappers/tests.py @@ -24,7 +24,7 @@ ) from django.test import SimpleTestCase from django.utils.functional import lazystr -from django.utils.http import MAX_URL_LENGTH +from django.utils.http import MAX_URL_REDIRECT_LENGTH class QueryDictTests(SimpleTestCase): @@ -486,12 +486,25 @@ def test_stream_interface(self): r.writelines(["foo\n", "bar\n", "baz\n"]) self.assertEqual(r.content, b"foo\nbar\nbaz\n") + def test_redirect_url_max_length(self): + base_url = "https://example.com/" + for length in ( + MAX_URL_REDIRECT_LENGTH - 1, + MAX_URL_REDIRECT_LENGTH, + ): + long_url = base_url + "x" * (length - len(base_url)) + with self.subTest(length=length): + response = HttpResponseRedirect(long_url) + self.assertEqual(response.url, long_url) + response = HttpResponsePermanentRedirect(long_url) + self.assertEqual(response.url, long_url) + def test_unsafe_redirect(self): bad_urls = [ 'data:text/html,', "mailto:test@example.com", "file:///etc/passwd", - "é" * (MAX_URL_LENGTH + 1), + "é" * (MAX_URL_REDIRECT_LENGTH + 1), ] for url in bad_urls: with self.assertRaises(DisallowedRedirect): From 18b13cf6c48ff0a20b2a74d3b90d1fc1602608e4 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:22:53 -0300 Subject: [PATCH 2/4] Refs #36743 -- Added missing release notes for 5.1.15 and 4.2.27. The fix landed in a8cf8c292cfee98fe6cc873ca5221935f1d02271 will be backported to 5.1 and 4.2 since the 2048 limit was rolled out as part of the security release for CVE-2025-64458. --- docs/releases/4.2.27.txt | 5 ++++- docs/releases/5.1.15.txt | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/releases/4.2.27.txt b/docs/releases/4.2.27.txt index e4a61d9568b4..7ffa5fa45858 100644 --- a/docs/releases/4.2.27.txt +++ b/docs/releases/4.2.27.txt @@ -10,4 +10,7 @@ with severity "moderate", and one bug in 4.2.26. Bugfixes ======== -* ... +* Fixed a regression in Django 4.2.26 where ``DisallowedRedirect`` was raised + by :class:`~django.http.HttpResponseRedirect` and + :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048 + characters. The limit is now 16384 characters (:ticket:`36743`). diff --git a/docs/releases/5.1.15.txt b/docs/releases/5.1.15.txt index 9e1bfa91b908..2c4e02959031 100644 --- a/docs/releases/5.1.15.txt +++ b/docs/releases/5.1.15.txt @@ -10,4 +10,7 @@ with severity "moderate", and one bug in 5.1.14. Bugfixes ======== -* ... +* Fixed a regression in Django 5.1.14 where ``DisallowedRedirect`` was raised + by :class:`~django.http.HttpResponseRedirect` and + :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048 + characters. The limit is now 16384 characters (:ticket:`36743`). From 532c1058a7dd2616181259c94eb92f2477038d2c Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:22:52 -0300 Subject: [PATCH 3/4] Added script to archive EOL stable branches. This also fixed a small bash issue in `confirm_release.sh` script. --- scripts/archive_eol_stable_branches.py | 151 +++++++++++++++++++++++++ scripts/confirm_release.sh | 2 +- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 scripts/archive_eol_stable_branches.py diff --git a/scripts/archive_eol_stable_branches.py b/scripts/archive_eol_stable_branches.py new file mode 100644 index 000000000000..c2cafc9c5c43 --- /dev/null +++ b/scripts/archive_eol_stable_branches.py @@ -0,0 +1,151 @@ +#! /usr/bin/env python3 +import argparse +import os +import subprocess +import sys + + +def run(cmd, *, cwd=None, env=None, dry_run=True): + """Run a command with optional dry-run behavior.""" + environ = os.environ.copy() + if env: + environ.update(env) + if dry_run: + print("[DRY RUN]", " ".join(cmd)) + else: + print("[EXECUTE]", " ".join(cmd)) + try: + result = subprocess.check_output( + cmd, cwd=cwd, env=environ, stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError as e: + result = e.output + print(" [ERROR]", result) + raise + else: + print(" [RESULT]", result) + return result.decode().strip() + + +def validate_env(checkout_dir): + if not checkout_dir: + sys.exit("Error: checkout directory not provided (--checkout-dir).") + if not os.path.exists(checkout_dir): + sys.exit(f"Error: checkout directory '{checkout_dir}' does not exist.") + if not os.path.isdir(checkout_dir): + sys.exit(f"Error: '{checkout_dir}' is not a directory.") + + +def get_remote_branches(checkout_dir, include_fn): + """Return list of remote branches filtered by include_fn.""" + result = run( + ["git", "branch", "--list", "-r"], + cwd=checkout_dir, + dry_run=False, + ) + branches = [b.strip() for b in result.split("\n") if b.strip()] + return [b for b in branches if include_fn(b)] + + +def get_branch_info(checkout_dir, branch): + """Return (commit_hash, last_update_date) for a given branch.""" + commit_hash = run(["git", "rev-parse", branch], cwd=checkout_dir, dry_run=False) + last_update = run( + ["git", "show", branch, "--format=format:%ai", "-s"], + cwd=checkout_dir, + dry_run=False, + ) + return commit_hash, last_update + + +def create_tag(checkout_dir, branch, commit_hash, last_update, *, dry_run=True): + """Create a tag locally for a given branch at its last update.""" + tag_name = branch.replace("origin/", "", 1) + msg = f'"Tagged {tag_name} for EOL stable branch removal."' + run( + ["git", "tag", "--sign", "--message", msg, tag_name, commit_hash], + cwd=checkout_dir, + env={"GIT_COMMITTER_DATE": last_update}, + dry_run=dry_run, + ) + return tag_name + + +def delete_remote_and_local_branch(checkout_dir, branch, *, dry_run=True): + """Delete a remote branch from origin and the maching local branch.""" + try: + run( + ["git", "branch", "-D", branch], + cwd=checkout_dir, + dry_run=dry_run, + ) + except subprocess.CalledProcessError: + print(f"[ERROR] Local branch {branch} can not be deleted.") + + run( + ["git", "push", "origin", "--delete", branch.replace("origin/", "", 1)], + cwd=checkout_dir, + dry_run=dry_run, + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Archive Django branches into tags and optionally delete them." + ) + parser.add_argument( + "--checkout-dir", required=True, help="Path to Django git checkout" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print commands instead of executing them", + ) + parser.add_argument( + "--branches", nargs="*", help="Specific remote branches to include (optional)" + ) + args = parser.parse_args() + + validate_env(args.checkout_dir) + dry_run = args.dry_run + checkout_dir = args.checkout_dir + + if args.branches: + wanted = set(f"origin/{b}" for b in args.branches) + else: + wanted = set() + + branches = get_remote_branches(checkout_dir, include_fn=lambda b: b in wanted) + if not branches: + print("No branches matched inclusion criteria.") + return + + print("\nMatched branches:") + print("\n".join(branches)) + print() + + branch_updates = {b: get_branch_info(checkout_dir, b) for b in branches} + print("\nLast updates:") + for b, (h, d) in branch_updates.items(): + print(f"{b}\t{h}\t{d}") + + if ( + input("\nDelete remote branches and create tags? [y/N]: ").strip().lower() + == "y" + ): + for b, (commit_hash, last_update_date) in branch_updates.items(): + print(f"Creating tag for {b} at {commit_hash=} with {last_update_date=}") + create_tag(checkout_dir, b, commit_hash, last_update_date, dry_run=dry_run) + print(f"Deleting remote branch {b}") + delete_remote_and_local_branch(checkout_dir, b, dry_run=dry_run) + run( + ["git", "push", "--tags"], + cwd=checkout_dir, + dry_run=dry_run, + ) + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/scripts/confirm_release.sh b/scripts/confirm_release.sh index 920f2061aff5..c3b4d12c5aaa 100755 --- a/scripts/confirm_release.sh +++ b/scripts/confirm_release.sh @@ -25,7 +25,7 @@ echo "Download checksum file ..." curl --fail --output "$CHECKSUM_FILE" "${MEDIA_URL_PREFIX}/pgp/${CHECKSUM_FILE}" echo "Verify checksum file ..." -if [ -n "${GPG_KEY}" ] ; then +if [ -n "${GPG_KEY:-}" ] ; then gpg --recv-keys "${GPG_KEY}" fi gpg --verify "${CHECKSUM_FILE}" From 60b08ad5e1701b1a9b2a03e5897670d5af32d379 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:23:42 -0300 Subject: [PATCH 4/4] Included usage of new scripts in docs/internals/howto-release-django.txt. --- docs/internals/howto-release-django.txt | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/internals/howto-release-django.txt b/docs/internals/howto-release-django.txt index 871d219aa08b..7d3d6ded2648 100644 --- a/docs/internals/howto-release-django.txt +++ b/docs/internals/howto-release-django.txt @@ -471,12 +471,25 @@ Building the artifacts .. admonition:: Optionally use helper scripts You can streamline some of the steps below using helper scripts from the - Wiki: + ``scripts`` folder: - * `Release script - `_ - * `Test new version script - `_ + * Release script example run: + + .. code-block:: shell + + $ PGP_KEY_ID= PGP_KEY_URL= DEST_FOLDER=~/releases scripts/do_django_release.py + + * Test new version script: + + .. code-block:: shell + + $ VERSION=5.2.1 scripts/test_new_version.sh + + * Confirm release signature script: + + .. code-block:: shell + + $ VERSION=5.2.1 scripts/confirm_release.sh #. Tag the release using ``git tag``. For example: @@ -692,6 +705,15 @@ You're almost done! All that's left to do now is: #. Update djangoproject.com's download page (`example PR `__). + #. Process the older versions that will reach End-Of-Mainstream and/or + End-Of-Life support when this final release is published: + + #. Ensure that the EOL versions are mentioned in the blog post. `Example + `_. + + #. Create a tag for the EOL stable branch and delete the stable branch. + Inspect and use the ``scripts/archive_eol_stable_branches.py`` helper. + #. If this was a security release, update :doc:`/releases/security` with details of the issues addressed.