1
0
mirror of https://github.com/django/django.git synced 2025-06-11 22:49:13 +00:00

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>
This commit is contained in:
Natalia 2025-05-20 15:29:52 -03:00
parent 08187c94ed
commit a07ebec559
5 changed files with 128 additions and 1 deletions

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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
========

View File

@ -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)