Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions django/http/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions django/utils/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
RFC3986_GENDELIMS = ":/?#[]@"
RFC3986_SUBDELIMS = "!$&'()*+,;="
MAX_URL_LENGTH = 2048
MAX_URL_REDIRECT_LENGTH = 16384


def urlencode(query, doseq=False):
Expand Down
32 changes: 27 additions & 5 deletions docs/internals/howto-release-django.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://code.djangoproject.com/wiki/ReleaseScript>`_
* `Test new version script
<https://code.djangoproject.com/wiki/ReleaseTestNewVersion>`_
* Release script example run:

.. code-block:: shell

$ PGP_KEY_ID=<key-id> PGP_KEY_URL=<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:

Expand Down Expand Up @@ -692,6 +705,15 @@ You're almost done! All that's left to do now is:
#. Update djangoproject.com's download page (`example PR
<https://github.com/django/djangoproject.com/pull/1444>`__).

#. 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
<https://www.djangoproject.com/weblog/2025/apr/02/django-52-released/>`_.

#. 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.

Expand Down
5 changes: 4 additions & 1 deletion docs/releases/4.2.27.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
5 changes: 4 additions & 1 deletion docs/releases/5.1.15.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
5 changes: 5 additions & 0 deletions docs/releases/5.2.9.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
151 changes: 151 additions & 0 deletions scripts/archive_eol_stable_branches.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion scripts/confirm_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
17 changes: 15 additions & 2 deletions tests/httpwrappers/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,<script>window.alert("xss")</script>',
"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):
Expand Down