diff mbox series

[meta-python,scarthgap,4/8] python3-django: patch CVE-2025-48432

Message ID 20251022061803.887676-4-ankur.tyagi85@gmail.com
State New
Headers show
Series [meta-python,scarthgap,1/8] python3-django: upgrade 4.2.18 -> 4.2.20 | expand

Commit Message

Ankur Tyagi Oct. 22, 2025, 6:17 a.m. UTC
https://nvd.nist.gov/vuln/detail/CVE-2025-48432

Following patches are needed to avoid cherry-pick conflicts
 - CVE-2025-48432-1.patch
 - CVE-2025-48432-2.patch
 - CVE-2025-48432-4.patch

Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
 .../CVE-2025-48432-1.patch                    | 166 +++++++++++++
 .../CVE-2025-48432-2.patch                    | 225 ++++++++++++++++++
 .../CVE-2025-48432-3.patch                    | 164 +++++++++++++
 .../CVE-2025-48432-4.patch                    | 193 +++++++++++++++
 .../CVE-2025-48432-5.patch                    |  76 ++++++
 .../CVE-2025-48432-6.patch                    | 144 +++++++++++
 .../python/python3-django_4.2.20.bb           |   6 +
 7 files changed, 974 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-1.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-2.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-3.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-4.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-5.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-6.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-1.patch b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-1.patch
new file mode 100644
index 0000000000..51196d0ceb
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-1.patch
@@ -0,0 +1,166 @@ 
+From d4a83d38022de809f4ae015ff4b7592c11f3b371 Mon Sep 17 00:00:00 2001
+From: Natalia <124304+nessita@users.noreply.github.com>
+Date: Mon, 19 May 2025 22:45:38 -0300
+Subject: [PATCH] [4.2.x] Refs #26688 -- Added tests for `log_response()`
+ internal helper.
+
+Backport of 897046815944cc9a2da7ed9e8082f45ffe8110e3 from main.
+
+CVE: CVE-2025-48432
+Upstream-Status: Backport [https://github.com/django/django/commit/acbe655a0fa1200d2de31c6020f310ba9aa2f636]
+(cherry picked from commit acbe655a0fa1200d2de31c6020f310ba9aa2f636)
+Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
+---
+ tests/logging_tests/tests.py | 121 +++++++++++++++++++++++++++++++++++
+ 1 file changed, 121 insertions(+)
+
+diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py
+index c73a3acd6d..2138a7fe50 100644
+--- a/tests/logging_tests/tests.py
++++ b/tests/logging_tests/tests.py
+@@ -1,6 +1,7 @@
+ import logging
+ from contextlib import contextmanager
+ from io import StringIO
++from unittest import TestCase
+ 
+ from admin_scripts.tests import AdminScriptTestCase
+ 
+@@ -9,6 +10,7 @@ from django.core import mail
+ from django.core.exceptions import DisallowedHost, PermissionDenied, SuspiciousOperation
+ from django.core.files.temp import NamedTemporaryFile
+ from django.core.management import color
++from django.http import HttpResponse
+ from django.http.multipartparser import MultiPartParserError
+ from django.test import RequestFactory, SimpleTestCase, override_settings
+ from django.test.utils import LoggingCaptureMixin
+@@ -19,6 +21,7 @@ from django.utils.log import (
+     RequireDebugFalse,
+     RequireDebugTrue,
+     ServerFormatter,
++    log_response,
+ )
+ from django.views.debug import ExceptionReporter
+ 
+@@ -646,3 +649,121 @@ class LogFormattersTests(SimpleTestCase):
+             self.assertRegex(
+                 logger_output.getvalue(), r"^\[[/:,\w\s\d]+\] %s\n" % log_msg
+             )
++
++
++class LogResponseRealLoggerTests(TestCase):
++    request = RequestFactory().get("/test-path/")
++
++    def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request):
++        self.assertEqual(
++            records_len := len(logger_cm.records),
++            1,
++            f"Unexpected number of records for {logger_cm=} in {levelno=} (expected 1, "
++            f"got {records_len}).",
++        )
++        record = logger_cm.records[0]
++        self.assertEqual(record.getMessage(), msg)
++        self.assertEqual(record.levelno, levelno)
++        self.assertEqual(record.status_code, status_code)
++        self.assertEqual(record.request, request)
++
++    def test_missing_response_raises_attribute_error(self):
++        with self.assertRaises(AttributeError):
++            log_response("No response provided", response=None, request=self.request)
++
++    def test_missing_request_logs_with_none(self):
++        response = HttpResponse(status=403)
++        with self.assertLogs("django.request", level="INFO") as cm:
++            log_response(msg := "Missing request", response=response, request=None)
++        self.assertResponseLogged(cm, msg, logging.WARNING, 403, request=None)
++
++    def test_logs_5xx_as_error(self):
++        response = HttpResponse(status=508)
++        with self.assertLogs("django.request", level="ERROR") as cm:
++            log_response(
++                msg := "Server error occurred", response=response, request=self.request
++            )
++        self.assertResponseLogged(cm, msg, logging.ERROR, 508, self.request)
++
++    def test_logs_4xx_as_warning(self):
++        response = HttpResponse(status=418)
++        with self.assertLogs("django.request", level="WARNING") as cm:
++            log_response(
++                msg := "This is a teapot!", response=response, request=self.request
++            )
++        self.assertResponseLogged(cm, msg, logging.WARNING, 418, self.request)
++
++    def test_logs_2xx_as_info(self):
++        response = HttpResponse(status=201)
++        with self.assertLogs("django.request", level="INFO") as cm:
++            log_response(msg := "OK response", response=response, request=self.request)
++        self.assertResponseLogged(cm, msg, logging.INFO, 201, self.request)
++
++    def test_custom_log_level(self):
++        response = HttpResponse(status=403)
++        with self.assertLogs("django.request", level="DEBUG") as cm:
++            log_response(
++                msg := "Debug level log",
++                response=response,
++                request=self.request,
++                level="debug",
++            )
++        self.assertResponseLogged(cm, msg, logging.DEBUG, 403, self.request)
++
++    def test_logs_only_once_per_response(self):
++        response = HttpResponse(status=500)
++        with self.assertLogs("django.request", level="ERROR") as cm:
++            log_response("First log", response=response, request=self.request)
++            log_response("Second log", response=response, request=self.request)
++        self.assertResponseLogged(cm, "First log", logging.ERROR, 500, self.request)
++
++    def test_exc_info_output(self):
++        response = HttpResponse(status=500)
++        try:
++            raise ValueError("Simulated failure")
++        except ValueError as exc:
++            with self.assertLogs("django.request", level="ERROR") as cm:
++                log_response(
++                    "With exception",
++                    response=response,
++                    request=self.request,
++                    exception=exc,
++                )
++        self.assertResponseLogged(
++            cm, "With exception", logging.ERROR, 500, self.request
++        )
++        self.assertIn("ValueError", "\n".join(cm.output))  # Stack trace included
++
++    def test_format_args_are_applied(self):
++        response = HttpResponse(status=500)
++        with self.assertLogs("django.request", level="ERROR") as cm:
++            log_response(
++                "Something went wrong: %s (%d)",
++                "DB error",
++                42,
++                response=response,
++                request=self.request,
++            )
++        msg = "Something went wrong: DB error (42)"
++        self.assertResponseLogged(cm, msg, logging.ERROR, 500, self.request)
++
++    def test_logs_with_custom_logger(self):
++        handler = logging.StreamHandler(log_stream := StringIO())
++        handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
++
++        custom_logger = logging.getLogger("my.custom.logger")
++        custom_logger.setLevel(logging.DEBUG)
++        custom_logger.addHandler(handler)
++        self.addCleanup(custom_logger.removeHandler, handler)
++
++        response = HttpResponse(status=404)
++        log_response(
++            msg := "Handled by custom logger",
++            response=response,
++            request=self.request,
++            logger=custom_logger,
++        )
++
++        self.assertEqual(
++            f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip()
++        )
diff --git a/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-2.patch b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-2.patch
new file mode 100644
index 0000000000..2824a9c0e3
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-2.patch
@@ -0,0 +1,225 @@ 
+From 88c0244497d492e84b4a2925ac4554698cdf179a Mon Sep 17 00:00:00 2001
+From: Natalia <124304+nessita@users.noreply.github.com>
+Date: Mon, 19 May 2025 22:46:00 -0300
+Subject: [PATCH] [4.2.x] Added helpers in csrf_tests and logging_tests to
+ assert logs from `log_response()`.
+
+Backport of ad6f99889838ccc2c30b3c02ed3868c9b565e81b from main.
+
+CVE: CVE-2025-48432
+Upstream-Status: Backport [https://github.com/django/django/commit/32fd8dec5618bd09eccdeb9dbf512043193d68ef]
+(cherry picked from commit 32fd8dec5618bd09eccdeb9dbf512043193d68ef)
+Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
+---
+ tests/csrf_tests/tests.py    | 53 ++++++++++++++++++------------------
+ tests/logging_tests/tests.py | 42 ++++++++++++++++++++--------
+ 2 files changed, 57 insertions(+), 38 deletions(-)
+
+diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py
+index ba8f87d6ac..b8d928151e 100644
+--- a/tests/csrf_tests/tests.py
++++ b/tests/csrf_tests/tests.py
+@@ -1,3 +1,4 @@
++import logging
+ import re
+ 
+ from django.conf import settings
+@@ -57,6 +58,21 @@ class CsrfFunctionTestMixin:
+         actual = _unmask_cipher_token(masked_secret)
+         self.assertEqual(actual, secret)
+ 
++    def assertForbiddenReason(
++        self, response, logger_cm, reason, levelno=logging.WARNING
++    ):
++        self.assertEqual(
++            records_len := len(logger_cm.records),
++            1,
++            f"Unexpected number of records for {logger_cm=} in {levelno=} (expected 1, "
++            f"got {records_len}).",
++        )
++        record = logger_cm.records[0]
++        self.assertEqual(record.getMessage(), "Forbidden (%s): " % reason)
++        self.assertEqual(record.levelno, levelno)
++        self.assertEqual(record.status_code, 403)
++        self.assertEqual(response.status_code, 403)
++
+ 
+ class CsrfFunctionTests(CsrfFunctionTestMixin, SimpleTestCase):
+     def test_unmask_cipher_token(self):
+@@ -347,8 +363,7 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         mw.process_request(req)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             resp = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(403, resp.status_code)
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % expected)
++        self.assertForbiddenReason(resp, cm, expected)
+ 
+     def test_no_csrf_cookie(self):
+         """
+@@ -373,9 +388,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         mw.process_request(req)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             resp = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(403, resp.status_code)
+         self.assertEqual(resp["Content-Type"], "text/html; charset=utf-8")
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % expected)
++        self.assertForbiddenReason(resp, cm, expected)
+ 
+     def test_csrf_cookie_bad_or_missing_token(self):
+         """
+@@ -480,18 +494,12 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         mw = CsrfViewMiddleware(post_form_view)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             resp = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(403, resp.status_code)
+-        self.assertEqual(
+-            cm.records[0].getMessage(), "Forbidden (%s): " % REASON_NO_CSRF_COOKIE
+-        )
++        self.assertForbiddenReason(resp, cm, REASON_NO_CSRF_COOKIE)
+ 
+         req = self._get_request(method="DELETE")
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             resp = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(403, resp.status_code)
+-        self.assertEqual(
+-            cm.records[0].getMessage(), "Forbidden (%s): " % REASON_NO_CSRF_COOKIE
+-        )
++        self.assertForbiddenReason(resp, cm, REASON_NO_CSRF_COOKIE)
+ 
+     def test_put_and_delete_allowed(self):
+         """
+@@ -879,11 +887,7 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         mw.process_request(req)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             resp = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(resp.status_code, 403)
+-        self.assertEqual(
+-            cm.records[0].getMessage(),
+-            "Forbidden (%s): " % REASON_CSRF_TOKEN_MISSING,
+-        )
++        self.assertForbiddenReason(resp, cm, REASON_CSRF_TOKEN_MISSING)
+ 
+     def test_reading_post_data_raises_os_error(self):
+         """
+@@ -908,9 +912,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         self.assertIs(mw._origin_verified(req), False)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             response = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(response.status_code, 403)
+         msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
++        self.assertForbiddenReason(response, cm, msg)
+ 
+     @override_settings(ALLOWED_HOSTS=["www.example.com"])
+     def test_bad_origin_null_origin(self):
+@@ -923,9 +926,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         self.assertIs(mw._origin_verified(req), False)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             response = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(response.status_code, 403)
+         msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
++        self.assertForbiddenReason(response, cm, msg)
+ 
+     @override_settings(ALLOWED_HOSTS=["www.example.com"])
+     def test_bad_origin_bad_protocol(self):
+@@ -939,9 +941,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         self.assertIs(mw._origin_verified(req), False)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             response = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(response.status_code, 403)
+         msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
++        self.assertForbiddenReason(response, cm, msg)
+ 
+     @override_settings(
+         ALLOWED_HOSTS=["www.example.com"],
+@@ -966,9 +967,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         self.assertIs(mw._origin_verified(req), False)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             response = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(response.status_code, 403)
+         msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
++        self.assertForbiddenReason(response, cm, msg)
+         self.assertEqual(mw.allowed_origins_exact, {"http://no-match.com"})
+         self.assertEqual(
+             mw.allowed_origin_subdomains,
+@@ -992,9 +992,8 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
+         self.assertIs(mw._origin_verified(req), False)
+         with self.assertLogs("django.security.csrf", "WARNING") as cm:
+             response = mw.process_view(req, post_form_view, (), {})
+-        self.assertEqual(response.status_code, 403)
+         msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
+-        self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
++        self.assertForbiddenReason(response, cm, msg)
+ 
+     @override_settings(ALLOWED_HOSTS=["www.example.com"])
+     def test_good_origin_insecure(self):
+diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py
+index 2138a7fe50..4ffa49a1b8 100644
+--- a/tests/logging_tests/tests.py
++++ b/tests/logging_tests/tests.py
+@@ -94,6 +94,28 @@ class DefaultLoggingTests(
+ 
+ 
+ class LoggingAssertionMixin:
++
++    def assertLogRecord(
++        self,
++        logger_cm,
++        level,
++        msg,
++        status_code,
++        exc_class=None,
++    ):
++        self.assertEqual(
++            records_len := len(logger_cm.records),
++            1,
++            f"Wrong number of calls for {logger_cm=} in {level=} (expected 1, got "
++            f"{records_len}).",
++        )
++        record = logger_cm.records[0]
++        self.assertEqual(record.getMessage(), msg)
++        self.assertEqual(record.status_code, status_code)
++        if exc_class:
++            self.assertIsNotNone(record.exc_info)
++            self.assertEqual(record.exc_info[0], exc_class)
++
+     def assertLogsRequest(
+         self, url, level, msg, status_code, logger="django.request", exc_class=None
+     ):
+@@ -102,17 +124,7 @@ class LoggingAssertionMixin:
+                 self.client.get(url)
+             except views.UncaughtException:
+                 pass
+-            self.assertEqual(
+-                len(cm.records),
+-                1,
+-                "Wrong number of calls for logger %r in %r level." % (logger, level),
+-            )
+-            record = cm.records[0]
+-            self.assertEqual(record.getMessage(), msg)
+-            self.assertEqual(record.status_code, status_code)
+-            if exc_class:
+-                self.assertIsNotNone(record.exc_info)
+-                self.assertEqual(record.exc_info[0], exc_class)
++            self.assertLogRecord(cm, level, msg, status_code, exc_class)
+ 
+ 
+ @override_settings(DEBUG=True, ROOT_URLCONF="logging_tests.urls")
+@@ -135,6 +147,14 @@ class HandlerLoggingTests(
+             msg="Not Found: /does_not_exist/",
+         )
+ 
++    async def test_async_page_not_found_warning(self):
++        logger = "django.request"
++        level = "WARNING"
++        with self.assertLogs(logger, level) as cm:
++            await self.async_client.get("/does_not_exist/")
++
++        self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404)
++
+     def test_page_not_found_raised(self):
+         self.assertLogsRequest(
+             url="/does_not_exist_raised/",
diff --git a/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-3.patch b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-3.patch
new file mode 100644
index 0000000000..58550567a4
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-django-4.2.20/CVE-2025-48432-3.patch
@@ -0,0 +1,164 @@ 
+From 5e137465b5b7668f9a32f5c4f4af374fd705f38d 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.
+CVE: CVE-2025-48432
+Upstream-Status: Backport [https://github.com/django/django/commit/ac03c5e7df8680c61cdb0d3bdb8be9095dba841e]
+(cherry picked from commit ac03c5e7df8680c61cdb0d3bdb8be9095dba841e)
+Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
+---
+ django/utils/log.py          |  7 +++-
+ tests/logging_tests/tests.py | 79 +++++++++++++++++++++++++++++++++++-
+ 2 files changed, 84 insertions(+), 2 deletions(-)
+
+diff --git a/django/utils/log.py b/django/utils/log.py
+index fd0cc1bdc1..d7465f73d7 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 4ffa49a1b8..cda0a62f2c 100644
+--- a/tests/logging_tests/tests.py
++++ b/tests/logging_tests/tests.py
+@@ -94,7 +94,6 @@ class DefaultLoggingTests(
+ 
+ 
+ class LoggingAssertionMixin:
+-
+     def assertLogRecord(
+         self,
+         logger_cm,
+@@ -147,6 +146,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 +162,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/",
+@@ -686,6 +703,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):
+@@ -787,3 +805,62 @@ 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