Skip to content

Commit 7456aa2

Browse files
committed
[5.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 3340d41 commit 7456aa2

File tree

5 files changed

+128
-1
lines changed

5 files changed

+128
-1
lines changed

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: 14 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

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)

0 commit comments

Comments
 (0)