Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 587a9c5

Browse files
committedJun 4, 2025
Merge remote-tracking branch 'upstream/main'
2 parents 6fe0e96 + 51923c5 commit 587a9c5

File tree

11 files changed

+177
-2
lines changed

11 files changed

+177
-2
lines changed
 

‎django/db/models/fields/related_descriptors.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,11 @@ def get_prefetch_querysets(self, instances, querysets=None):
169169
rel_obj_attr = self.field.get_foreign_related_value
170170
instance_attr = self.field.get_local_related_value
171171
instances_dict = {instance_attr(inst): inst for inst in instances}
172-
related_fields = self.field.foreign_related_fields
173172
remote_field = self.field.remote_field
173+
related_fields = [
174+
queryset.query.resolve_ref(field.name).target
175+
for field in self.field.foreign_related_fields
176+
]
174177
queryset = queryset.filter(
175178
TupleIn(
176179
ColPairs(

‎django/utils/log.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,14 @@ def log_response(
245245
else:
246246
level = "info"
247247

248+
escaped_args = tuple(
249+
a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a
250+
for a in args
251+
)
252+
248253
getattr(logger, level)(
249254
message,
250-
*args,
255+
*escaped_args,
251256
extra={
252257
"status_code": response.status_code,
253258
"request": request,

‎docs/releases/4.2.22.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,17 @@ Django 4.2.22 release notes
55
*June 4, 2025*
66

77
Django 4.2.22 fixes a security issue with severity "low" in 4.2.21.
8+
9+
CVE-2025-48432: Potential log injection via unescaped request path
10+
==================================================================
11+
12+
Internal HTTP response logging used ``request.path`` directly, allowing control
13+
characters (e.g. newlines or ANSI escape sequences) to be written unescaped
14+
into logs. This could enable log injection or forgery, letting attackers
15+
manipulate log appearance or structure, especially in logs processed by
16+
external systems or viewed in terminals.
17+
18+
Although this does not directly impact Django's security model, it poses risks
19+
when logs are consumed or interpreted by other tools. To fix this, the internal
20+
``django.utils.log.log_response()`` function now escapes all positional
21+
formatting arguments using a safe encoding.

‎docs/releases/5.1.10.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,17 @@ Django 5.1.10 release notes
55
*June 4, 2025*
66

77
Django 5.1.10 fixes a security issue with severity "low" in 5.1.9.
8+
9+
CVE-2025-48432: Potential log injection via unescaped request path
10+
==================================================================
11+
12+
Internal HTTP response logging used ``request.path`` directly, allowing control
13+
characters (e.g. newlines or ANSI escape sequences) to be written unescaped
14+
into logs. This could enable log injection or forgery, letting attackers
15+
manipulate log appearance or structure, especially in logs processed by
16+
external systems or viewed in terminals.
17+
18+
Although this does not directly impact Django's security model, it poses risks
19+
when logs are consumed or interpreted by other tools. To fix this, the internal
20+
``django.utils.log.log_response()`` function now escapes all positional
21+
formatting arguments using a safe encoding.

‎docs/releases/5.2.2.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ Django 5.2.2 release notes
77
Django 5.2.2 fixes a security issue with severity "low" and several bugs in
88
5.2.1.
99

10+
CVE-2025-48432: Potential log injection via unescaped request path
11+
==================================================================
12+
13+
Internal HTTP response logging used ``request.path`` directly, allowing control
14+
characters (e.g. newlines or ANSI escape sequences) to be written unescaped
15+
into logs. This could enable log injection or forgery, letting attackers
16+
manipulate log appearance or structure, especially in logs processed by
17+
external systems or viewed in terminals.
18+
19+
Although this does not directly impact Django's security model, it poses risks
20+
when logs are consumed or interpreted by other tools. To fix this, the internal
21+
``django.utils.log.log_response()`` function now escapes all positional
22+
formatting arguments using a safe encoding.
23+
1024
Bugfixes
1125
========
1226

@@ -43,3 +57,7 @@ Bugfixes
4357
<django.http.HttpRequest.get_preferred_type>` did not account for media type
4458
parameters in ``Accept`` headers, reducing specificity in content negotiation
4559
(:ticket:`36411`).
60+
61+
* Fixed a regression in Django 5.2 that caused a crash when using
62+
``QuerySet.prefetch_related()`` to prefetch a foreign key with a ``Prefetch``
63+
queryset for a subclass of the foreign target (:ticket:`36432`).

‎docs/releases/5.2.3.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
==========================
2+
Django 5.2.3 release notes
3+
==========================
4+
5+
*Expected July 2, 2025*
6+
7+
Django 5.2.3 fixes several bugs in 5.2.2.
8+
9+
Bugfixes
10+
========
11+
12+
* ...

‎docs/releases/index.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ versions of the documentation contain the release notes for any later releases.
3232
.. toctree::
3333
:maxdepth: 1
3434

35+
5.2.3
3536
5.2.2
3637
5.2.1
3738
5.2

‎docs/releases/security.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ Issues under Django's security process
3636
All security issues have been handled under versions of Django's security
3737
process. These are listed below.
3838

39+
June 4, 2025 - :cve:`2025-48432`
40+
--------------------------------
41+
42+
Potential log injection via unescaped request path.
43+
`Full description
44+
<https://www.djangoproject.com/weblog/2025/jun/04/security-releases/>`__
45+
46+
* Django 5.2 :commit:`(patch) <7456aa23dafa149e65e62f95a6550cdb241d55ad>`
47+
* Django 5.1 :commit:`(patch) <596542ddb46cdabe011322917e1655f0d24eece2>`
48+
* Django 4.2 :commit:`(patch) <ac03c5e7df8680c61cdb0d3bdb8be9095dba841e>`
49+
3950
May 7, 2025 - :cve:`2025-32873`
4051
-------------------------------
4152

‎tests/logging_tests/tests.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ def test_page_not_found_warning(self):
147147
msg="Not Found: /does_not_exist/",
148148
)
149149

150+
def test_control_chars_escaped(self):
151+
self.assertLogsRequest(
152+
url="/%1B[1;31mNOW IN RED!!!1B[0m/",
153+
level="WARNING",
154+
status_code=404,
155+
msg=r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/",
156+
)
157+
150158
async def test_async_page_not_found_warning(self):
151159
logger = "django.request"
152160
level = "WARNING"
@@ -155,6 +163,16 @@ async def test_async_page_not_found_warning(self):
155163

156164
self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404)
157165

166+
async def test_async_control_chars_escaped(self):
167+
logger = "django.request"
168+
level = "WARNING"
169+
with self.assertLogs(logger, level) as cm:
170+
await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/")
171+
172+
self.assertLogRecord(
173+
cm, level, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", 404
174+
)
175+
158176
def test_page_not_found_raised(self):
159177
self.assertLogsRequest(
160178
url="/does_not_exist_raised/",
@@ -705,6 +723,7 @@ def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request):
705723
self.assertEqual(record.levelno, levelno)
706724
self.assertEqual(record.status_code, status_code)
707725
self.assertEqual(record.request, request)
726+
return record
708727

709728
def test_missing_response_raises_attribute_error(self):
710729
with self.assertRaises(AttributeError):
@@ -806,3 +825,64 @@ def test_logs_with_custom_logger(self):
806825
self.assertEqual(
807826
f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip()
808827
)
828+
829+
def test_unicode_escape_escaping(self):
830+
test_cases = [
831+
# Control characters.
832+
("line\nbreak", "line\\nbreak"),
833+
("carriage\rreturn", "carriage\\rreturn"),
834+
("tab\tseparated", "tab\\tseparated"),
835+
("formfeed\f", "formfeed\\x0c"),
836+
("bell\a", "bell\\x07"),
837+
("multi\nline\ntext", "multi\\nline\\ntext"),
838+
# Slashes.
839+
("slash\\test", "slash\\\\test"),
840+
("back\\slash", "back\\\\slash"),
841+
# Quotes.
842+
('quote"test"', 'quote"test"'),
843+
("quote'test'", "quote'test'"),
844+
# Accented, composed characters, emojis and symbols.
845+
("café", "caf\\xe9"),
846+
("e\u0301", "e\\u0301"), # e + combining acute
847+
("smile🙂", "smile\\U0001f642"),
848+
("weird ☃️", "weird \\u2603\\ufe0f"),
849+
# Non-Latin alphabets.
850+
("Привет", "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"),
851+
("你好", "\\u4f60\\u597d"),
852+
# ANSI escape sequences.
853+
("escape\x1b[31mred\x1b[0m", "escape\\x1b[31mred\\x1b[0m"),
854+
(
855+
"/\x1b[1;31mCAUTION!!YOU ARE PWNED\x1b[0m/",
856+
"/\\x1b[1;31mCAUTION!!YOU ARE PWNED\\x1b[0m/",
857+
),
858+
(
859+
"/\r\n\r\n1984-04-22 INFO Listening on 0.0.0.0:8080\r\n\r\n",
860+
"/\\r\\n\\r\\n1984-04-22 INFO Listening on 0.0.0.0:8080\\r\\n\\r\\n",
861+
),
862+
# Plain safe input.
863+
("normal-path", "normal-path"),
864+
("slash/colon:", "slash/colon:"),
865+
# Non strings.
866+
(0, "0"),
867+
([1, 2, 3], "[1, 2, 3]"),
868+
({"test": "🙂"}, "{'test': '🙂'}"),
869+
]
870+
871+
msg = "Test message: %s"
872+
for case, expected in test_cases:
873+
with (
874+
self.assertLogs("django.request", level="ERROR") as cm,
875+
self.subTest(case=case),
876+
):
877+
response = HttpResponse(status=318)
878+
log_response(msg, case, response=response, level="error")
879+
880+
record = self.assertResponseLogged(
881+
cm,
882+
msg % expected,
883+
levelno=logging.ERROR,
884+
status_code=318,
885+
request=None,
886+
)
887+
# Log record is always a single line.
888+
self.assertEqual(len(record.getMessage().splitlines()), 1)

‎tests/prefetch_related/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ class Meta:
280280
ordering = ["id"]
281281

282282

283+
class SelfDirectedEmployee(Employee):
284+
pass
285+
286+
283287
# Ticket #19607
284288

285289

‎tests/prefetch_related/tests.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
Qualification,
3838
Reader,
3939
Room,
40+
SelfDirectedEmployee,
4041
TaggedItem,
4142
Teacher,
4243
WordEntry,
@@ -433,6 +434,18 @@ def test_m2m_join_reuse(self):
433434
authors[1].active_favorite_authors, [self.author3, self.author4]
434435
)
435436

437+
def test_prefetch_queryset_child_class(self):
438+
employee = SelfDirectedEmployee.objects.create(name="Foo")
439+
employee.boss = employee
440+
employee.save()
441+
with self.assertNumQueries(2):
442+
retrieved_employee = SelfDirectedEmployee.objects.prefetch_related(
443+
Prefetch("boss", SelfDirectedEmployee.objects.all())
444+
).get()
445+
with self.assertNumQueries(0):
446+
self.assertEqual(retrieved_employee, employee)
447+
self.assertEqual(retrieved_employee.boss, retrieved_employee)
448+
436449

437450
class RawQuerySetTests(TestDataMixin, TestCase):
438451
def test_basic(self):

0 commit comments

Comments
 (0)
Please sign in to comment.