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/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. 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`). 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/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}" 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):