Skip to content

Commit ac03c5e

Browse files
committed
[4.2.x] Fixed CVE-2025-48432 -- Escaped formatting arguments in log_response().
Suitably crafted requests containing a CRLF sequence in the request path may have allowed log injection, potentially corrupting log files, obscuring other attacks, misleading log post-processing tools, or forging log entries. To mitigate this, all positional formatting arguments passed to the logger are now escaped using "unicode_escape" encoding. Thanks to Seokchan Yoon (https://ch4n3.kr/) for the report. Co-authored-by: Carlton Gibson <carlton@noumenal.es> Co-authored-by: Jake Howard <git@theorangeone.net> Backport of a07ebec from main.
1 parent c62f4ee commit ac03c5e

File tree

3 files changed

+98
-2
lines changed

3 files changed

+98
-2
lines changed

django/utils/log.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,14 @@ def log_response(
238238
else:
239239
level = "info"
240240

241+
escaped_args = tuple(
242+
a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a
243+
for a in args
244+
)
245+
241246
getattr(logger, level)(
242247
message,
243-
*args,
248+
*escaped_args,
244249
extra={
245250
"status_code": response.status_code,
246251
"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.

tests/logging_tests/tests.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ def test_django_logger_debug(self):
9494

9595

9696
class LoggingAssertionMixin:
97-
9897
def assertLogRecord(
9998
self,
10099
logger_cm,
@@ -147,6 +146,14 @@ def test_page_not_found_warning(self):
147146
msg="Not Found: /does_not_exist/",
148147
)
149148

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

156163
self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404)
157164

165+
async def test_async_control_chars_escaped(self):
166+
logger = "django.request"
167+
level = "WARNING"
168+
with self.assertLogs(logger, level) as cm:
169+
await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/")
170+
171+
self.assertLogRecord(
172+
cm, level, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", 404
173+
)
174+
158175
def test_page_not_found_raised(self):
159176
self.assertLogsRequest(
160177
url="/does_not_exist_raised/",
@@ -686,6 +703,7 @@ def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request):
686703
self.assertEqual(record.levelno, levelno)
687704
self.assertEqual(record.status_code, status_code)
688705
self.assertEqual(record.request, request)
706+
return record
689707

690708
def test_missing_response_raises_attribute_error(self):
691709
with self.assertRaises(AttributeError):
@@ -787,3 +805,62 @@ def test_logs_with_custom_logger(self):
787805
self.assertEqual(
788806
f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip()
789807
)
808+
809+
def test_unicode_escape_escaping(self):
810+
test_cases = [
811+
# Control characters.
812+
("line\nbreak", "line\\nbreak"),
813+
("carriage\rreturn", "carriage\\rreturn"),
814+
("tab\tseparated", "tab\\tseparated"),
815+
("formfeed\f", "formfeed\\x0c"),
816+
("bell\a", "bell\\x07"),
817+
("multi\nline\ntext", "multi\\nline\\ntext"),
818+
# Slashes.
819+
("slash\\test", "slash\\\\test"),
820+
("back\\slash", "back\\\\slash"),
821+
# Quotes.
822+
('quote"test"', 'quote"test"'),
823+
("quote'test'", "quote'test'"),
824+
# Accented, composed characters, emojis and symbols.
825+
("café", "caf\\xe9"),
826+
("e\u0301", "e\\u0301"), # e + combining acute
827+
("smile🙂", "smile\\U0001f642"),
828+
("weird ☃️", "weird \\u2603\\ufe0f"),
829+
# Non-Latin alphabets.
830+
("Привет", "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"),
831+
("你好", "\\u4f60\\u597d"),
832+
# ANSI escape sequences.
833+
("escape\x1b[31mred\x1b[0m", "escape\\x1b[31mred\\x1b[0m"),
834+
(
835+
"/\x1b[1;31mCAUTION!!YOU ARE PWNED\x1b[0m/",
836+
"/\\x1b[1;31mCAUTION!!YOU ARE PWNED\\x1b[0m/",
837+
),
838+
(
839+
"/\r\n\r\n1984-04-22 INFO Listening on 0.0.0.0:8080\r\n\r\n",
840+
"/\\r\\n\\r\\n1984-04-22 INFO Listening on 0.0.0.0:8080\\r\\n\\r\\n",
841+
),
842+
# Plain safe input.
843+
("normal-path", "normal-path"),
844+
("slash/colon:", "slash/colon:"),
845+
# Non strings.
846+
(0, "0"),
847+
([1, 2, 3], "[1, 2, 3]"),
848+
({"test": "🙂"}, "{'test': '🙂'}"),
849+
]
850+
851+
msg = "Test message: %s"
852+
for case, expected in test_cases:
853+
with self.assertLogs("django.request", level="ERROR") as cm:
854+
with self.subTest(case=case):
855+
response = HttpResponse(status=318)
856+
log_response(msg, case, response=response, level="error")
857+
858+
record = self.assertResponseLogged(
859+
cm,
860+
msg % expected,
861+
levelno=logging.ERROR,
862+
status_code=318,
863+
request=None,
864+
)
865+
# Log record is always a single line.
866+
self.assertEqual(len(record.getMessage().splitlines()), 1)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy