summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNatalia <124304+nessita@users.noreply.github.com>2025-05-20 15:29:52 -0300
committerNatalia <124304+nessita@users.noreply.github.com>2025-06-04 08:34:51 -0300
commit7456aa23dafa149e65e62f95a6550cdb241d55ad (patch)
treec3871a872bd11d57aa9286050d43295e6e1b5702
parent3340d4144605bd150906d070ae3e910941fa83c9 (diff)
[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 a07ebec5591e233d8bbb38b7d63f35c5479eef0e from main.
-rw-r--r--django/utils/log.py7
-rw-r--r--docs/releases/4.2.22.txt14
-rw-r--r--docs/releases/5.1.10.txt14
-rw-r--r--docs/releases/5.2.2.txt14
-rw-r--r--tests/logging_tests/tests.py80
5 files changed, 128 insertions, 1 deletions
diff --git a/django/utils/log.py b/django/utils/log.py
index a25b97a7d5..67a40270f0 100644
--- a/django/utils/log.py
+++ b/django/utils/log.py
@@ -245,9 +245,14 @@ def log_response(
else:
level = "info"
+ escaped_args = tuple(
+ a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a
+ for a in args
+ )
+
getattr(logger, level)(
message,
- *args,
+ *escaped_args,
extra={
"status_code": response.status_code,
"request": request,
diff --git a/docs/releases/4.2.22.txt b/docs/releases/4.2.22.txt
index 83c49b787b..ba3cc33248 100644
--- a/docs/releases/4.2.22.txt
+++ b/docs/releases/4.2.22.txt
@@ -5,3 +5,17 @@ Django 4.2.22 release notes
*June 4, 2025*
Django 4.2.22 fixes a security issue with severity "low" in 4.2.21.
+
+CVE-2025-48432: Potential log injection via unescaped request path
+==================================================================
+
+Internal HTTP response logging used ``request.path`` directly, allowing control
+characters (e.g. newlines or ANSI escape sequences) to be written unescaped
+into logs. This could enable log injection or forgery, letting attackers
+manipulate log appearance or structure, especially in logs processed by
+external systems or viewed in terminals.
+
+Although this does not directly impact Django's security model, it poses risks
+when logs are consumed or interpreted by other tools. To fix this, the internal
+``django.utils.log.log_response()`` function now escapes all positional
+formatting arguments using a safe encoding.
diff --git a/docs/releases/5.1.10.txt b/docs/releases/5.1.10.txt
index 7f2d4c2499..b5cc1f89a1 100644
--- a/docs/releases/5.1.10.txt
+++ b/docs/releases/5.1.10.txt
@@ -5,3 +5,17 @@ Django 5.1.10 release notes
*June 4, 2025*
Django 5.1.10 fixes a security issue with severity "low" in 5.1.9.
+
+CVE-2025-48432: Potential log injection via unescaped request path
+==================================================================
+
+Internal HTTP response logging used ``request.path`` directly, allowing control
+characters (e.g. newlines or ANSI escape sequences) to be written unescaped
+into logs. This could enable log injection or forgery, letting attackers
+manipulate log appearance or structure, especially in logs processed by
+external systems or viewed in terminals.
+
+Although this does not directly impact Django's security model, it poses risks
+when logs are consumed or interpreted by other tools. To fix this, the internal
+``django.utils.log.log_response()`` function now escapes all positional
+formatting arguments using a safe encoding.
diff --git a/docs/releases/5.2.2.txt b/docs/releases/5.2.2.txt
index 56efb69bfb..556e5b3d50 100644
--- a/docs/releases/5.2.2.txt
+++ b/docs/releases/5.2.2.txt
@@ -7,6 +7,20 @@ Django 5.2.2 release notes
Django 5.2.2 fixes a security issue with severity "low" and several bugs in
5.2.1.
+CVE-2025-48432: Potential log injection via unescaped request path
+==================================================================
+
+Internal HTTP response logging used ``request.path`` directly, allowing control
+characters (e.g. newlines or ANSI escape sequences) to be written unescaped
+into logs. This could enable log injection or forgery, letting attackers
+manipulate log appearance or structure, especially in logs processed by
+external systems or viewed in terminals.
+
+Although this does not directly impact Django's security model, it poses risks
+when logs are consumed or interpreted by other tools. To fix this, the internal
+``django.utils.log.log_response()`` function now escapes all positional
+formatting arguments using a safe encoding.
+
Bugfixes
========
diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py
index a03881472f..f01ccb0c86 100644
--- a/tests/logging_tests/tests.py
+++ b/tests/logging_tests/tests.py
@@ -147,6 +147,14 @@ class HandlerLoggingTests(
msg="Not Found: /does_not_exist/",
)
+ def test_control_chars_escaped(self):
+ self.assertLogsRequest(
+ url="/%1B[1;31mNOW IN RED!!!1B[0m/",
+ level="WARNING",
+ status_code=404,
+ msg=r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/",
+ )
+
async def test_async_page_not_found_warning(self):
logger = "django.request"
level = "WARNING"
@@ -155,6 +163,16 @@ class HandlerLoggingTests(
self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404)
+ async def test_async_control_chars_escaped(self):
+ logger = "django.request"
+ level = "WARNING"
+ with self.assertLogs(logger, level) as cm:
+ await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/")
+
+ self.assertLogRecord(
+ cm, level, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", 404
+ )
+
def test_page_not_found_raised(self):
self.assertLogsRequest(
url="/does_not_exist_raised/",
@@ -705,6 +723,7 @@ class LogResponseRealLoggerTests(TestCase):
self.assertEqual(record.levelno, levelno)
self.assertEqual(record.status_code, status_code)
self.assertEqual(record.request, request)
+ return record
def test_missing_response_raises_attribute_error(self):
with self.assertRaises(AttributeError):
@@ -806,3 +825,64 @@ class LogResponseRealLoggerTests(TestCase):
self.assertEqual(
f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip()
)
+
+ def test_unicode_escape_escaping(self):
+ test_cases = [
+ # Control characters.
+ ("line\nbreak", "line\\nbreak"),
+ ("carriage\rreturn", "carriage\\rreturn"),
+ ("tab\tseparated", "tab\\tseparated"),
+ ("formfeed\f", "formfeed\\x0c"),
+ ("bell\a", "bell\\x07"),
+ ("multi\nline\ntext", "multi\\nline\\ntext"),
+ # Slashes.
+ ("slash\\test", "slash\\\\test"),
+ ("back\\slash", "back\\\\slash"),
+ # Quotes.
+ ('quote"test"', 'quote"test"'),
+ ("quote'test'", "quote'test'"),
+ # Accented, composed characters, emojis and symbols.
+ ("café", "caf\\xe9"),
+ ("e\u0301", "e\\u0301"), # e + combining acute
+ ("smile🙂", "smile\\U0001f642"),
+ ("weird ☃️", "weird \\u2603\\ufe0f"),
+ # Non-Latin alphabets.
+ ("Привет", "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"),
+ ("你好", "\\u4f60\\u597d"),
+ # ANSI escape sequences.
+ ("escape\x1b[31mred\x1b[0m", "escape\\x1b[31mred\\x1b[0m"),
+ (
+ "/\x1b[1;31mCAUTION!!YOU ARE PWNED\x1b[0m/",
+ "/\\x1b[1;31mCAUTION!!YOU ARE PWNED\\x1b[0m/",
+ ),
+ (
+ "/\r\n\r\n1984-04-22 INFO Listening on 0.0.0.0:8080\r\n\r\n",
+ "/\\r\\n\\r\\n1984-04-22 INFO Listening on 0.0.0.0:8080\\r\\n\\r\\n",
+ ),
+ # Plain safe input.
+ ("normal-path", "normal-path"),
+ ("slash/colon:", "slash/colon:"),
+ # Non strings.
+ (0, "0"),
+ ([1, 2, 3], "[1, 2, 3]"),
+ ({"test": "🙂"}, "{'test': '🙂'}"),
+ ]
+
+ msg = "Test message: %s"
+ for case, expected in test_cases:
+ with (
+ self.assertLogs("django.request", level="ERROR") as cm,
+ self.subTest(case=case),
+ ):
+ response = HttpResponse(status=318)
+ log_response(msg, case, response=response, level="error")
+
+ record = self.assertResponseLogged(
+ cm,
+ msg % expected,
+ levelno=logging.ERROR,
+ status_code=318,
+ request=None,
+ )
+ # Log record is always a single line.
+ self.assertEqual(len(record.getMessage().splitlines()), 1)