CVE-2025-13473 CVE-2025-14550 CVE-2026-1207 CVE-2026-1285 CVE-2026-1287 CVE-2026-1312 #4

Open
mcalabkova wants to merge 2 commits from mcalabkova/python-Django:leap-16.0 into leap-16.0
11 changed files with 949 additions and 12 deletions

121
CVE-2025-13473.patch Normal file
View File

@@ -0,0 +1,121 @@
From 184e38ab0a061c365f5775676a074796d8abd02f Mon Sep 17 00:00:00 2001
From: Jake Howard <git@theorangeone.net>
Date: Wed, 19 Nov 2025 16:52:28 +0000
Subject: [PATCH] [5.2.x] Fixed CVE-2025-13473 -- Standardized timing of
check_password() in mod_wsgi auth handler.
Refs CVE-2024-39329, #20760.
Thanks Stackered for the report, and Jacob Walls and Markus Holtermann
for the reviews.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Backport of 3eb814e02a4c336866d4189fa0c24fd1875863ed from main.
---
django/contrib/auth/handlers/modwsgi.py | 37 ++++++++++++++++++++-----
docs/releases/4.2.28.txt | 10 +++++++
docs/releases/5.2.11.txt | 10 +++++++
tests/auth_tests/test_handlers.py | 28 +++++++++++++++++++
4 files changed, 78 insertions(+), 7 deletions(-)
diff --git a/django/contrib/auth/handlers/modwsgi.py b/django/contrib/auth/handlers/modwsgi.py
index 591ec72cb4cd..086db89fc846 100644
--- a/django/contrib/auth/handlers/modwsgi.py
+++ b/django/contrib/auth/handlers/modwsgi.py
@@ -4,24 +4,47 @@
UserModel = auth.get_user_model()
+def _get_user(username):
+ """
+ Return the UserModel instance for `username`.
+
+ If no matching user exists, or if the user is inactive, return None, in
+ which case the default password hasher is run to mitigate timing attacks.
+ """
+ try:
+ user = UserModel._default_manager.get_by_natural_key(username)
+ except UserModel.DoesNotExist:
+ user = None
+ else:
+ if not user.is_active:
+ user = None
+
+ if user is None:
+ # Run the default password hasher once to reduce the timing difference
+ # between existing/active and nonexistent/inactive users (#20760).
+ UserModel().set_password("")
+
+ return user
+
+
def check_password(environ, username, password):
"""
Authenticate against Django's auth database.
mod_wsgi docs specify None, True, False as return value depending
on whether the user exists and authenticates.
+
+ Return None if the user does not exist, return False if the user exists but
+ password is not correct, and return True otherwise.
+
"""
# db connection state is managed similarly to the wsgi handler
# as mod_wsgi may call these functions outside of a request/response cycle
db.reset_queries()
try:
- try:
- user = UserModel._default_manager.get_by_natural_key(username)
- except UserModel.DoesNotExist:
- return None
- if not user.is_active:
- return None
- return user.check_password(password)
+ user = _get_user(username)
+ if user:
+ return user.check_password(password)
finally:
db.close_old_connections()
diff --git a/tests/auth_tests/test_handlers.py b/tests/auth_tests/test_handlers.py
index a6b53a9ef11d..32b4371198d4 100644
--- a/tests/auth_tests/test_handlers.py
+++ b/tests/auth_tests/test_handlers.py
@@ -1,4 +1,7 @@
+from unittest import mock
+
from django.contrib.auth.handlers.modwsgi import check_password, groups_for_user
+from django.contrib.auth.hashers import get_hasher
from django.contrib.auth.models import Group, User
from django.test import TransactionTestCase, override_settings
@@ -73,3 +76,28 @@ def test_groups_for_user(self):
self.assertEqual(groups_for_user({}, "test"), [b"test_group"])
self.assertEqual(groups_for_user({}, "test1"), [])
+
+ def test_check_password_fake_runtime(self):
+ """
+ Hasher is run once regardless of whether the user exists. Refs #20760.
+ """
+ User.objects.create_user("test", "test@example.com", "test")
+ User.objects.create_user("inactive", "test@nono.com", "test", is_active=False)
+ User.objects.create_user("unusable", "test@nono.com")
+
+ hasher = get_hasher()
+
+ for username, password in [
+ ("test", "test"),
+ ("test", "wrong"),
+ ("inactive", "test"),
+ ("inactive", "wrong"),
+ ("unusable", "test"),
+ ("doesnotexist", "test"),
+ ]:
+ with (
+ self.subTest(username=username, password=password),
+ mock.patch.object(hasher, "encode") as mock_make_password,
+ ):
+ check_password({}, username, password)
+ mock_make_password.assert_called_once()

95
CVE-2025-14550.patch Normal file
View File

@@ -0,0 +1,95 @@
From 1ba90069c12836db46981bdf75b0e661db5849ce Mon Sep 17 00:00:00 2001
From: Jake Howard <git@theorangeone.net>
Date: Wed, 14 Jan 2026 15:25:45 +0000
Subject: [PATCH] [5.2.x] Fixed CVE-2025-14550 -- Optimized repeated header
parsing in ASGI requests.
Thanks Jiyong Yang for the report, and Natalia Bidart, Jacob Walls, and
Shai Berger for reviews.
Backport of eb22e1d6d643360e952609ef562c139a100ea4eb from main.
---
django/core/handlers/asgi.py | 7 ++++---
docs/releases/4.2.28.txt | 12 ++++++++++++
docs/releases/5.2.11.txt | 12 ++++++++++++
tests/asgi/tests.py | 28 +++++++++++++++++++++++++++-
4 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
index bb6a6bfb3ce7..2dfcc7f31d3f 100644
--- a/django/core/handlers/asgi.py
+++ b/django/core/handlers/asgi.py
@@ -3,6 +3,7 @@
import sys
import tempfile
import traceback
+from collections import defaultdict
from contextlib import aclosing
from asgiref.sync import ThreadSensitiveContext, sync_to_async
@@ -83,6 +84,7 @@ def __init__(self, scope, body_file):
self.META["SERVER_NAME"] = "unknown"
self.META["SERVER_PORT"] = "0"
# Headers go into META.
+ _headers = defaultdict(list)
for name, value in self.scope.get("headers", []):
name = name.decode("latin1")
if name == "content-length":
@@ -94,9 +96,8 @@ def __init__(self, scope, body_file):
# HTTP/2 say only ASCII chars are allowed in headers, but decode
# latin1 just in case.
value = value.decode("latin1")
- if corrected_name in self.META:
- value = self.META[corrected_name] + "," + value
- self.META[corrected_name] = value
+ _headers[corrected_name].append(value)
+ self.META.update({name: ",".join(value) for name, value in _headers.items()})
# Pull out request encoding, if provided.
self._set_content_type_params(self.META)
# Directly assign the body file to be our stream.
diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py
index 0b1d3cd60879..81a53e539742 100644
--- a/tests/asgi/tests.py
+++ b/tests/asgi/tests.py
@@ -211,7 +211,7 @@ async def test_post_body(self):
self.assertEqual(response_body["type"], "http.response.body")
self.assertEqual(response_body["body"], b"Echo!")
- async def test_create_request_error(self):
+ async def test_request_too_big_request_error(self):
# Track request_finished signal.
signal_handler = SignalHandler()
request_finished.connect(signal_handler)
@@ -242,6 +242,32 @@ class TestASGIHandler(ASGIHandler):
signal_handler.calls[0]["thread"], threading.current_thread()
)
+ async def test_meta_not_modified_with_repeat_headers(self):
+ scope = self.async_request_factory._base_scope(path="/", http_version="2.0")
+ scope["headers"] = [(b"foo", b"bar")] * 200_000
+
+ setitem_count = 0
+
+ class InstrumentedDict(dict):
+ def __setitem__(self, *args, **kwargs):
+ nonlocal setitem_count
+ setitem_count += 1
+ super().__setitem__(*args, **kwargs)
+
+ class InstrumentedASGIRequest(ASGIRequest):
+ @property
+ def META(self):
+ return self._meta
+
+ @META.setter
+ def META(self, value):
+ self._meta = InstrumentedDict(**value)
+
+ request = InstrumentedASGIRequest(scope, None)
+
+ self.assertEqual(len(request.headers["foo"].split(",")), 200_000)
+ self.assertLessEqual(setitem_count, 100)
+
async def test_cancel_post_request_with_sync_processing(self):
"""
The request.body object should be available and readable in view

106
CVE-2026-1207.patch Normal file
View File

@@ -0,0 +1,106 @@
From 17a1d64a58ef24c0c3b78d66d86f5415075f18f0 Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Mon, 19 Jan 2026 15:42:33 -0500
Subject: [PATCH] [5.2.x] Fixed CVE-2026-1207 -- Prevented SQL injections in
RasterField lookups via band index.
Thanks Tarek Nakkouch for the report, and Simon Charette for the initial
triage and review.
Backport of 81aa5292967cd09319c45fe2c1a525ce7b6684d8 from main.
---
.../gis/db/backends/postgis/operations.py | 6 +++
docs/releases/4.2.28.txt | 12 +++++
docs/releases/5.2.11.txt | 12 +++++
tests/gis_tests/rasterapp/test_rasterfield.py | 47 ++++++++++++++++++-
4 files changed, 76 insertions(+), 1 deletion(-)
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index 7a347c52878a..c39f756c792e 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -51,6 +51,9 @@ def check_raster(self, lookup, template_params):
# Look for band indices and inject them if provided.
if lookup.band_lhs is not None and lhs_is_raster:
+ if not isinstance(lookup.band_lhs, int):
+ name = lookup.band_lhs.__class__.__name__
+ raise TypeError(f"Band index must be an integer, but got {name!r}.")
if not self.func:
raise ValueError(
"Band indices are not allowed for this operator, it works on bbox "
@@ -62,6 +65,9 @@ def check_raster(self, lookup, template_params):
)
if lookup.band_rhs is not None and rhs_is_raster:
+ if not isinstance(lookup.band_rhs, int):
+ name = lookup.band_rhs.__class__.__name__
+ raise TypeError(f"Band index must be an integer, but got {name!r}.")
if not self.func:
raise ValueError(
"Band indices are not allowed for this operator, it works on bbox "
diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py b/tests/gis_tests/rasterapp/test_rasterfield.py
index 3f2ce770a9a6..89c4ec4856a7 100644
--- a/tests/gis_tests/rasterapp/test_rasterfield.py
+++ b/tests/gis_tests/rasterapp/test_rasterfield.py
@@ -2,7 +2,11 @@
from django.contrib.gis.db.models.fields import BaseSpatialField
from django.contrib.gis.db.models.functions import Distance
-from django.contrib.gis.db.models.lookups import DistanceLookupBase, GISLookup
+from django.contrib.gis.db.models.lookups import (
+ DistanceLookupBase,
+ GISLookup,
+ RasterBandTransform,
+)
from django.contrib.gis.gdal import GDALRaster
from django.contrib.gis.geos import GEOSGeometry
from django.contrib.gis.measure import D
@@ -356,6 +360,47 @@ def test_lookup_input_band_not_allowed(self):
with self.assertRaisesMessage(ValueError, msg):
qs.count()
+ def test_lookup_invalid_band_rhs(self):
+ rast = GDALRaster(json.loads(JSON_RASTER))
+ qs = RasterModel.objects.filter(rast__contains=(rast, "evil"))
+ msg = "Band index must be an integer, but got 'str'."
+ with self.assertRaisesMessage(TypeError, msg):
+ qs.count()
+
+ def test_lookup_invalid_band_lhs(self):
+ """
+ Typical left-hand side usage is protected against non-integers, but for
+ defense-in-depth purposes, construct custom lookups that evade the
+ `int()` and `+ 1` checks in the lookups shipped by django.contrib.gis.
+ """
+
+ # Evade the int() call in RasterField.get_transform().
+ class MyRasterBandTransform(RasterBandTransform):
+ band_index = "evil"
+
+ def process_band_indices(self, *args, **kwargs):
+ self.band_lhs = self.lhs.band_index
+ self.band_rhs, *self.rhs_params = self.rhs_params
+
+ # Evade the `+ 1` call in BaseSpatialField.process_band_indices().
+ ContainsLookup = RasterModel._meta.get_field("rast").get_lookup("contains")
+
+ class MyContainsLookup(ContainsLookup):
+ def process_band_indices(self, *args, **kwargs):
+ self.band_lhs = self.lhs.band_index
+ self.band_rhs, *self.rhs_params = self.rhs_params
+
+ RasterField = RasterModel._meta.get_field("rast")
+ RasterField.register_lookup(MyContainsLookup, "contains")
+ self.addCleanup(RasterField.register_lookup, ContainsLookup, "contains")
+
+ qs = RasterModel.objects.annotate(
+ transformed=MyRasterBandTransform("rast")
+ ).filter(transformed__contains=(F("transformed"), 1))
+ msg = "Band index must be an integer, but got 'str'."
+ with self.assertRaisesMessage(TypeError, msg):
+ list(qs)
+
def test_isvalid_lookup_with_raster_error(self):
qs = RasterModel.objects.filter(rast__isvalid=True)
msg = (

69
CVE-2026-1285.patch Normal file
View File

@@ -0,0 +1,69 @@
From 9f2ada875bbee62ac46032e38ddb22755d67ae5a Mon Sep 17 00:00:00 2001
From: Natalia <124304+nessita@users.noreply.github.com>
Date: Wed, 21 Jan 2026 09:53:10 -0300
Subject: [PATCH] [5.2.x] Fixed CVE-2026-1285 -- Mitigated potential DoS in
django.utils.text.Truncator for HTML input.
The `TruncateHTMLParser` used `deque.remove()` to remove tags from the
stack when processing end tags. With crafted input containing many
unmatched end tags, this caused repeated full scans of the tag stack,
leading to quadratic time complexity.
The fix uses LIFO semantics, only removing a tag from the stack when it
matches the most recently opened tag. This avoids linear scans for
unmatched end tags and reduces complexity to linear time.
Refs #30686 and 6ee37ada3241ed263d8d1c2901b030d964cbd161.
Thanks Seokchan Yoon for the report, and Jake Howard and Jacob Walls for
reviews.
Backport of a33540b3e20b5d759aa8b2e4b9ca0e8edd285344 from main.
---
django/utils/text.py | 9 +++++----
docs/releases/4.2.28.txt | 12 ++++++++++++
docs/releases/5.2.11.txt | 12 ++++++++++++
tests/utils_tests/test_text.py | 10 ++++++++++
4 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/django/utils/text.py b/django/utils/text.py
index 26edde99e336..21efb00b98e8 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -126,10 +126,11 @@ def handle_starttag(self, tag, attrs):
def handle_endtag(self, tag):
if tag not in self.void_elements:
self.output += f"</{tag}>"
- try:
- self.tags.remove(tag)
- except ValueError:
- pass
+ # Remove from the stack only if the tag matches the most recently
+ # opened tag (LIFO). This avoids O(n) linear scans for unmatched
+ # end tags if `deque.remove()` would be called.
+ if self.tags and self.tags[0] == tag:
+ self.tags.popleft()
def handle_data(self, data):
data, output = self.process(data)
diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index 63c7889cbcec..11c01874cb5d 100644
--- a/tests/utils_tests/test_text.py
+++ b/tests/utils_tests/test_text.py
@@ -202,6 +202,16 @@ def test_truncate_chars_html_with_html_entities(self):
truncator = text.Truncator("<p>I &lt;3 python, what about you?</p>")
self.assertEqual("<p>I &lt;3 python, wh…</p>", truncator.chars(16, html=True))
+ def test_truncate_chars_html_with_misnested_tags(self):
+ # LIFO removal keeps all tags when a middle tag is closed out of order.
+ # With <a><b><c></b>, the </b> doesn't match <c>, so all tags remain
+ # in the stack and are properly closed at truncation.
+ truncator = text.Truncator("<a><b><c></b>XXXX")
+ self.assertEqual(
+ truncator.chars(2, html=True, truncate=""),
+ "<a><b><c></b>XX</c></b></a>",
+ )
+
def test_truncate_words(self):
truncator = text.Truncator("The quick brown fox jumped over the lazy dog.")
self.assertEqual(

301
CVE-2026-1287.patch Normal file
View File

@@ -0,0 +1,301 @@
From 3e68ccdc11c127758745ddf0b4954990b14892bc Mon Sep 17 00:00:00 2001
From: Jake Howard <git@theorangeone.net>
Date: Wed, 21 Jan 2026 11:14:48 +0000
Subject: [PATCH] [5.2.x] Fixed CVE-2026-1287 -- Protected against SQL
injection in column aliases via control characters.
Control characters in FilteredRelation column aliases could be used for
SQL injection attacks. This affected QuerySet.annotate(), aggregate(),
extra(), values(), values_list(), and alias() when using dictionary
expansion with **kwargs.
Thanks Solomon Kebede for the report, and Simon Charette, Jacob Walls,
and Natalia Bidart for reviews.
Backport of e891a84c7ef9962bfcc3b4685690219542f86a22 from main.
---
django/db/models/sql/query.py | 10 +--
docs/releases/4.2.28.txt | 13 ++++
docs/releases/5.2.11.txt | 13 ++++
tests/aggregation/tests.py | 18 ++++--
tests/annotations/tests.py | 74 +++++++++++++++--------
tests/expressions/test_queryset_values.py | 36 +++++++----
tests/queries/tests.py | 18 ++++--
7 files changed, 128 insertions(+), 54 deletions(-)
diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
index 3a1cd739511e..baeac3a05be2 100644
--- a/django/db/models/sql/query.py
+++ b/django/db/models/sql/query.py
@@ -48,9 +48,11 @@
__all__ = ["Query", "RawQuery"]
-# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline
-# SQL comments are forbidden in column aliases.
-FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/")
+# Quotation marks ('"`[]), whitespace characters, control characters,
+# semicolons, hashes, or inline SQL comments are forbidden in column aliases.
+FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(
+ r"['`\"\]\[;\s\x00-\x1F\x7F-\x9F]|#|--|/\*|\*/"
+)
# Inspired from
# https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
@@ -1209,7 +1211,7 @@ def check_alias(self, alias):
if FORBIDDEN_ALIAS_PATTERN.search(alias):
raise ValueError(
"Column aliases cannot contain whitespace characters, hashes, "
- "quotation marks, semicolons, or SQL comments."
+ "control characters, quotation marks, semicolons, or SQL comments."
)
def add_annotation(self, annotation, alias, select=True):
diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py
index 2e41f1994723..f621c53e918a 100644
--- a/tests/aggregation/tests.py
+++ b/tests/aggregation/tests.py
@@ -2,6 +2,7 @@
import math
import re
from decimal import Decimal
+from itertools import chain
from django.core.exceptions import FieldError
from django.db import connection
@@ -2134,13 +2135,18 @@ def test_exists_none_with_aggregate(self):
self.assertEqual(len(qs), 6)
def test_alias_sql_injection(self):
- crafted_alias = """injected_name" from "aggregation_author"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Author.objects.aggregate(**{crafted_alias: Avg("age")})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "aggregation_author"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Author.objects.aggregate(**{crafted_alias: Avg("age")})
def test_exists_extra_where_with_aggregate(self):
qs = Book.objects.annotate(
diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py
index 78e5408d0fe7..0430c68f9175 100644
--- a/tests/annotations/tests.py
+++ b/tests/annotations/tests.py
@@ -1,5 +1,6 @@
import datetime
from decimal import Decimal
+from itertools import chain
from unittest import skipUnless
from django.core.exceptions import FieldDoesNotExist, FieldError
@@ -1157,22 +1158,32 @@ def test_annotation_aggregate_with_m2o(self):
)
def test_alias_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.annotate(**{crafted_alias: Value(1)})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.annotate(**{crafted_alias: Value(1)})
def test_alias_filtered_relation_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.annotate(**{crafted_alias: FilteredRelation("author")})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.annotate(**{crafted_alias: FilteredRelation("author")})
def test_alias_forbidden_chars(self):
tests = [
@@ -1190,10 +1201,11 @@ def test_alias_forbidden_chars(self):
"alias[",
"alias]",
"ali#as",
+ "ali\0as",
]
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
for crafted_alias in tests:
with self.subTest(crafted_alias):
@@ -1491,22 +1503,32 @@ def test_alias_after_values(self):
self.assertEqual(qs.get(pk=self.b1.pk), (self.b1.pk,))
def test_alias_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.alias(**{crafted_alias: Value(1)})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.alias(**{crafted_alias: Value(1)})
def test_alias_filtered_relation_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.alias(**{crafted_alias: FilteredRelation("authors")})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.alias(**{crafted_alias: FilteredRelation("authors")})
def test_alias_filtered_relation_sql_injection_dollar_sign(self):
qs = Book.objects.alias(
diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py
index 080ee06183dc..afd8a51159d5 100644
--- a/tests/expressions/test_queryset_values.py
+++ b/tests/expressions/test_queryset_values.py
@@ -1,3 +1,5 @@
+from itertools import chain
+
from django.db.models import F, Sum
from django.test import TestCase, skipUnlessDBFeature
@@ -35,26 +37,36 @@ def test_values_expression(self):
)
def test_values_expression_alias_sql_injection(self):
- crafted_alias = """injected_name" from "expressions_company"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
- with self.assertRaisesMessage(ValueError, msg):
- Company.objects.values(**{crafted_alias: F("ceo__salary")})
+ for crafted_alias in [
+ """injected_name" from "expressions_company"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Company.objects.values(**{crafted_alias: F("ceo__salary")})
@skipUnlessDBFeature("supports_json_field")
def test_values_expression_alias_sql_injection_json_field(self):
- crafted_alias = """injected_name" from "expressions_company"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
- with self.assertRaisesMessage(ValueError, msg):
- JSONFieldModel.objects.values(f"data__{crafted_alias}")
+ for crafted_alias in [
+ """injected_name" from "expressions_company"; --""",
+ # Control characters.
+ *(chr(c) for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ JSONFieldModel.objects.values(f"data__{crafted_alias}")
- with self.assertRaisesMessage(ValueError, msg):
- JSONFieldModel.objects.values_list(f"data__{crafted_alias}")
+ with self.assertRaisesMessage(ValueError, msg):
+ JSONFieldModel.objects.values_list(f"data__{crafted_alias}")
def test_values_expression_group_by(self):
# values() applies annotate() first, so values selected are grouped by
diff --git a/tests/queries/tests.py b/tests/queries/tests.py
index c1589669b073..69d728b1cd36 100644
--- a/tests/queries/tests.py
+++ b/tests/queries/tests.py
@@ -2,6 +2,7 @@
import pickle
import sys
import unittest
+from itertools import chain
from operator import attrgetter
from django.core.exceptions import EmptyResultSet, FieldError, FullResultSet
@@ -1959,13 +1960,18 @@ def test_extra_select_literal_percent_s(self):
)
def test_extra_select_alias_sql_injection(self):
- crafted_alias = """injected_name" from "queries_note"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes, quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Note.objects.extra(select={crafted_alias: "1"})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "queries_note"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Note.objects.extra(select={crafted_alias: "1"})
def test_queryset_reuse(self):
# Using querysets doesn't mutate aliases.

View File

@@ -0,0 +1,93 @@
From ab0ad8d39555292b55123adeac57ed64c776f8d9 Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Wed, 21 Jan 2026 18:00:13 -0500
Subject: [PATCH] [5.2.x] Refs CVE-2026-1312 -- Raised ValueError when
FilteredRelation aliases contain periods.
This prevents failures at the database layer, given that aliases in the
ON clause are not quoted.
Systematically quoting aliases even in FilteredRelation is tracked in
https://code.djangoproject.com/ticket/36795.
Backport of 005d60d97c4dfb117503bdb6f2facfcaf9315d84 from main.
---
django/db/models/sql/query.py | 5 +++++
tests/filtered_relation/tests.py | 13 +++++++++++++
tests/ordering/tests.py | 11 ++++++++---
3 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
index baeac3a05be2..324e605b8eea 100644
--- a/django/db/models/sql/query.py
+++ b/django/db/models/sql/query.py
@@ -1698,6 +1698,11 @@ def _add_q(
return target_clause, needed_inner
def add_filtered_relation(self, filtered_relation, alias):
+ if "." in alias:
+ raise ValueError(
+ "FilteredRelation doesn't support aliases with periods "
+ "(got %r)." % alias
+ )
self.check_alias(alias)
filtered_relation.alias = alias
relation_lookup_parts, relation_field_parts, _ = self.solve_lookup_type(
diff --git a/tests/filtered_relation/tests.py b/tests/filtered_relation/tests.py
index a9c6e00567cf..a4561d59f95f 100644
--- a/tests/filtered_relation/tests.py
+++ b/tests/filtered_relation/tests.py
@@ -216,6 +216,19 @@ def test_internal_queryset_alias_mapping(self):
str(queryset.query),
)
+ def test_period_forbidden(self):
+ msg = (
+ "FilteredRelation doesn't support aliases with periods (got 'book.alice')."
+ )
+ with self.assertRaisesMessage(ValueError, msg):
+ Author.objects.annotate(
+ **{
+ "book.alice": FilteredRelation(
+ "book", condition=Q(book__title__iexact="poem by alice")
+ )
+ }
+ )
+
def test_multiple(self):
qs = (
Author.objects.annotate(
diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py
index 530a27920e9d..30da025875bd 100644
--- a/tests/ordering/tests.py
+++ b/tests/ordering/tests.py
@@ -15,7 +15,6 @@
Value,
)
from django.db.models.functions import Length, Upper
-from django.db.utils import DatabaseError
from django.test import TestCase
from .models import (
@@ -408,13 +407,19 @@ def test_alias_with_period_shadows_table_name(self):
self.assertNotEqual(qs[0].headline, "Backdated")
relation = FilteredRelation("author")
- qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted)
- with self.assertRaises(DatabaseError):
+ msg = (
+ "FilteredRelation doesn't support aliases with periods "
+ "(got 'ordering_article.pub_date')."
+ )
+ with self.assertRaisesMessage(ValueError, msg):
+ qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted)
# Before, unlike F(), which causes ordering expressions to be
# replaced by ordinals like n in ORDER BY n, these were ordered by
# pub_date instead of author.
# The Article model orders by -pk, so sorting on author will place
# first any article by author2 instead of the backdated one.
+ # This assertion is reachable if FilteredRelation.__init__() starts
+ # supporting periods in aliases in the future.
self.assertNotEqual(qs2[0].headline, "Backdated")
def test_order_by_pk(self):

92
CVE-2026-1312.patch Normal file
View File

@@ -0,0 +1,92 @@
From e863ee273c6553e9b6fa4960a17acb535851857b Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Wed, 21 Jan 2026 17:53:52 -0500
Subject: [PATCH] [5.2.x] Fixed CVE-2026-1312 -- Protected order_by() from SQL
injection via aliases with periods.
Before, `order_by()` treated a period in a field name as a sign that it
was requested via `.extra(order_by=...)` and thus should be passed
through as raw table and column names, even if `extra()` was not used.
Since periods are permitted in aliases, this meant user-controlled
aliases could force the `order_by()` clause to resolve to a raw table
and column pair instead of the actual target field for the alias.
In practice, only `FilteredRelation` was affected, as the other
expressions we tested, e.g. `F`, aggressively optimize away the ordering
expressions into ordinal positions, e.g. ORDER BY 2, instead of ORDER BY
"table".column.
Thanks Solomon Kebede for the report, and Simon Charette and Jake Howard
for reviews.
Backport of 69065ca869b0970dff8fdd8fafb390bf8b3bf222 from main.
---
django/db/models/sql/compiler.py | 2 +-
docs/releases/4.2.28.txt | 10 ++++++++++
docs/releases/5.2.11.txt | 10 ++++++++++
tests/ordering/tests.py | 25 +++++++++++++++++++++++++
4 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py
index 4292243c71ee..7dbdec363568 100644
--- a/django/db/models/sql/compiler.py
+++ b/django/db/models/sql/compiler.py
@@ -433,7 +433,7 @@ def _order_by_pairs(self):
yield OrderBy(expr, descending=descending), False
continue
- if "." in field:
+ if "." in field and field in self.query.extra_order_by:
# This came in through an extra(order_by=...) addition. Pass it
# on verbatim.
table, col = col.split(".", 1)
diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py
index b29404ed77da..530a27920e9d 100644
--- a/tests/ordering/tests.py
+++ b/tests/ordering/tests.py
@@ -7,6 +7,7 @@
Count,
DateTimeField,
F,
+ FilteredRelation,
Max,
OrderBy,
OuterRef,
@@ -14,6 +15,7 @@
Value,
)
from django.db.models.functions import Length, Upper
+from django.db.utils import DatabaseError
from django.test import TestCase
from .models import (
@@ -392,6 +394,29 @@ def test_extra_ordering_with_table_name(self):
attrgetter("headline"),
)
+ def test_alias_with_period_shadows_table_name(self):
+ """
+ Aliases with periods are not confused for table names from extra().
+ """
+ Article.objects.update(author=self.author_2)
+ Article.objects.create(
+ headline="Backdated", pub_date=datetime(1900, 1, 1), author=self.author_1
+ )
+ crafted = "ordering_article.pub_date"
+
+ qs = Article.objects.annotate(**{crafted: F("author")}).order_by("-" + crafted)
+ self.assertNotEqual(qs[0].headline, "Backdated")
+
+ relation = FilteredRelation("author")
+ qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted)
+ with self.assertRaises(DatabaseError):
+ # Before, unlike F(), which causes ordering expressions to be
+ # replaced by ordinals like n in ORDER BY n, these were ordered by
+ # pub_date instead of author.
+ # The Article model orders by -pk, so sorting on author will place
+ # first any article by author2 instead of the backdated one.
+ self.assertNotEqual(qs2[0].headline, "Backdated")
+
def test_order_by_pk(self):
"""
'pk' works as an ordering option in Meta.

View File

@@ -1,3 +1,22 @@
-------------------------------------------------------------------
Wed Feb 4 13:21:44 UTC 2026 - Markéta Machová <mmachova@suse.com>
- Add test_strip_tags_incomplete.patch to fix behaviour with changes
in the Python interpreter
- Rebase test_strip_tags.patch
-------------------------------------------------------------------
Wed Feb 4 11:00:19 UTC 2026 - Markéta Machová <mmachova@suse.com>
- Add security patches:
* CVE-2025-14550.patch (bsc#1257403)
* CVE-2026-1312.patch (bsc#1257408)
* CVE-2026-1312-followup.patch (bsc#1257408)
* CVE-2026-1287.patch (bsc#1257407)
* CVE-2026-1207.patch (bsc#1257405)
* CVE-2025-13473.patch (bsc#1257401)
* CVE-2026-1285.patch (bsc#1257406)
-------------------------------------------------------------------
Mon Dec 8 11:04:00 UTC 2025 - Markéta Machová <mmachova@suse.com>

View File

@@ -30,8 +30,11 @@ Source: https://www.djangoproject.com/m/releases/5.2/django-%{version}.t
Source1: https://www.djangoproject.com/m/pgp/Django-%{version}.checksum.txt
Source2: %{name}.keyring
Source99: python-Django-rpmlintrc
# PATCH-FIX-UPSTREAM https://github.com/django/django/pull/20390 Refs #36499 -- Adjusted test_strip_tags following Python behavior change for incomplete entities.
Patch100: test_strip_tags_incomplete.patch
# PATCH-FIX-UPSTREAM https://github.com/django/django/pull/19639 Fixed #36499 -- Adjusted utils_tests.test_html.TestUtilsHtml.test_strip_tags following Python's HTMLParser new behavior.
Patch0: test_strip_tags.patch
# fixed and refined upstream, but some of our interpreters weren't updated to a new version yet and still only carry the patch, so providing the non-conditional version
Patch101: test_strip_tags.patch
# PATCH-FIX-UPSTREAM CVE-2025-57833.patch bsc#1248810
Patch1: CVE-2025-57833.patch
# PATCH-FIX-UPSTREAM CVE-2025-59681.patch bsc#1250485
@@ -44,6 +47,20 @@ Patch4: CVE-2025-64459.patch
Patch5: CVE-2025-13372.patch
# PATCH-FIX-UPSTREAM CVE-2025-64460.patch bsc#1254437
Patch6: CVE-2025-64460.patch
# PATCH-FIX-UPSTREAM CVE-2025-13473.patch bsc#1257401
Patch7: CVE-2025-13473.patch
# PATCH-FIX-UPSTREAM CVE-2025-14550.patch bsc#1257403
Patch8: CVE-2025-14550.patch
# PATCH-FIX-UPSTREAM CVE-2026-1207.patch bsc#1257405
Patch9: CVE-2026-1207.patch
# PATCH-FIX-UPSTREAM CVE-2026-1285.patch bsc#1257406
Patch10: CVE-2026-1285.patch
# PATCH-FIX-UPSTREAM CVE-2026-1287.patch bsc#1257407
Patch11: CVE-2026-1287.patch
# PATCH-FIX-UPSTREAM CVE-2026-1312.patch bsc#1257408
Patch12: CVE-2026-1312.patch
# PATCH-FIX-UPSTREAM CVE-2026-1312-followup.patch bsc#1257408
Patch13: CVE-2026-1312-followup.patch
BuildRequires: %{python_module Jinja2 >= 2.9.2}
BuildRequires: %{python_module Pillow >= 6.2.0}
BuildRequires: %{python_module PyYAML}

View File

@@ -10,11 +10,11 @@ Subject: [PATCH] Fixed #36499 -- Adjusted
tests/utils_tests/test_html.py | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py
index 494a0ea8d384..0de19eae9072 100644
--- a/tests/test_utils/tests.py
+++ b/tests/test_utils/tests.py
@@ -959,10 +959,10 @@ def test_parsing_errors(self):
Index: django-5.2.4/tests/test_utils/tests.py
===================================================================
--- django-5.2.4.orig/tests/test_utils/tests.py
+++ django-5.2.4/tests/test_utils/tests.py
@@ -945,10 +945,10 @@ class HTMLEqualTests(SimpleTestCase):
self.assertHTMLEqual("", "<p>")
error_msg = (
"First argument is not valid HTML:\n"
@@ -27,14 +27,14 @@ index 494a0ea8d384..0de19eae9072 100644
with self.assertRaises(HTMLParseError):
parse_html("</p>")
diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py
index 4ce552e79a0d..205eaeca1668 100644
--- a/tests/utils_tests/test_html.py
+++ b/tests/utils_tests/test_html.py
@@ -142,10 +142,10 @@ def test_strip_tags(self):
Index: django-5.2.4/tests/utils_tests/test_html.py
===================================================================
--- django-5.2.4.orig/tests/utils_tests/test_html.py
+++ django-5.2.4/tests/utils_tests/test_html.py
@@ -144,10 +144,10 @@ class TestUtilsHtml(SimpleTestCase):
("&gotcha&#;<>", "&gotcha&#;<>"),
("<sc<!-- -->ript>test<<!-- -->/script>", "ript>test"),
("<script>alert()</script>&h", "alert()h"),
("<script>alert()</script>&h", "alert()&h;"),
- ("><!" + ("&" * 16000) + "D", "><!" + ("&" * 16000) + "D"),
+ ("><!" + ("&" * 16000) + "D", ">"),
("X<<<<br>br>br>br>X", "XX"),

View File

@@ -0,0 +1,24 @@
From 5ca0f62213911a77dd4a62e843db7e420cc98b78 Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Thu, 11 Dec 2025 08:44:19 -0500
Subject: [PATCH] [5.2.x] Refs #36499 -- Adjusted test_strip_tags following
Python behavior change for incomplete entities.
Backport of 7b80b2186300620931009fd62c2969f108fe7a62 from main.
---
tests/utils_tests/test_html.py | 35 +++++++++++++++++++++++++++++-----
1 file changed, 30 insertions(+), 5 deletions(-)
Index: django-5.2.4/tests/utils_tests/test_html.py
===================================================================
--- django-5.2.4.orig/tests/utils_tests/test_html.py
+++ django-5.2.4/tests/utils_tests/test_html.py
@@ -143,7 +144,7 @@ class TestUtilsHtml(SimpleTestCase):
# https://bugs.python.org/issue20288
("&gotcha&#;<>", "&gotcha&#;<>"),
("<sc<!-- -->ript>test<<!-- -->/script>", "ript>test"),
- ("<script>alert()</script>&h", "alert()h"),
+ ("<script>alert()</script>&h", "alert()&h;"),
("><!" + ("&" * 16000) + "D", "><!" + ("&" * 16000) + "D"),
("X<<<<br>br>br>br>X", "XX"),
("<" * 50 + "a>" * 50, ""),