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)