File CVE-2025-48432.patch of Package python-Django.39541

From ac03c5e7df8680c61cdb0d3bdb8be9095dba841e Mon Sep 17 00:00:00 2001
From: Natalia <124304+nessita@users.noreply.github.com>
Date: Tue, 20 May 2025 15:29:52 -0300
Subject: [PATCH] [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 a07ebec5591e233d8bbb38b7d63f35c5479eef0e from main.
---
 django/utils/log.py          |  7 +++-
 docs/releases/4.2.22.txt     | 14 +++++++
 tests/logging_tests/tests.py | 79 +++++++++++++++++++++++++++++++++++-
 3 files changed, 98 insertions(+), 2 deletions(-)

diff --git a/django/utils/log.py b/django/utils/log.py
index fd0cc1bdc1ff..d7465f73d75c 100644
--- a/django/utils/log.py
+++ b/django/utils/log.py
@@ -238,9 +238,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/tests/logging_tests/tests.py b/tests/logging_tests/tests.py
index 4ffa49a1b805..cda0a62f2c16 100644
--- a/tests/logging_tests/tests.py
+++ b/tests/logging_tests/tests.py
@@ -94,7 +94,6 @@ def test_django_logger_debug(self):
 
 
 class LoggingAssertionMixin:
-
     def assertLogRecord(
         self,
         logger_cm,
@@ -147,6 +146,14 @@ def test_page_not_found_warning(self):
             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 +162,16 @@ async def test_async_page_not_found_warning(self):
 
         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/",
@@ -686,6 +703,7 @@ def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request):
         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):
@@ -787,3 +805,62 @@ def test_logs_with_custom_logger(self):
         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:
+                with 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)
openSUSE Build Service is sponsored by