mirror of
https://github.com/django/django.git
synced 2025-06-15 16:39:13 +00:00
[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.
This commit is contained in:
parent
3340d41446
commit
7456aa23da
@ -245,9 +245,14 @@ def log_response(
|
|||||||
else:
|
else:
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
|
escaped_args = tuple(
|
||||||
|
a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a
|
||||||
|
for a in args
|
||||||
|
)
|
||||||
|
|
||||||
getattr(logger, level)(
|
getattr(logger, level)(
|
||||||
message,
|
message,
|
||||||
*args,
|
*escaped_args,
|
||||||
extra={
|
extra={
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"request": request,
|
"request": request,
|
||||||
|
@ -5,3 +5,17 @@ Django 4.2.22 release notes
|
|||||||
*June 4, 2025*
|
*June 4, 2025*
|
||||||
|
|
||||||
Django 4.2.22 fixes a security issue with severity "low" in 4.2.21.
|
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.
|
||||||
|
@ -5,3 +5,17 @@ Django 5.1.10 release notes
|
|||||||
*June 4, 2025*
|
*June 4, 2025*
|
||||||
|
|
||||||
Django 5.1.10 fixes a security issue with severity "low" in 5.1.9.
|
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.
|
||||||
|
@ -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
|
Django 5.2.2 fixes a security issue with severity "low" and several bugs in
|
||||||
5.2.1.
|
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
|
Bugfixes
|
||||||
========
|
========
|
||||||
|
|
||||||
|
@ -147,6 +147,14 @@ class HandlerLoggingTests(
|
|||||||
msg="Not Found: /does_not_exist/",
|
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):
|
async def test_async_page_not_found_warning(self):
|
||||||
logger = "django.request"
|
logger = "django.request"
|
||||||
level = "WARNING"
|
level = "WARNING"
|
||||||
@ -155,6 +163,16 @@ class HandlerLoggingTests(
|
|||||||
|
|
||||||
self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404)
|
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):
|
def test_page_not_found_raised(self):
|
||||||
self.assertLogsRequest(
|
self.assertLogsRequest(
|
||||||
url="/does_not_exist_raised/",
|
url="/does_not_exist_raised/",
|
||||||
@ -705,6 +723,7 @@ class LogResponseRealLoggerTests(TestCase):
|
|||||||
self.assertEqual(record.levelno, levelno)
|
self.assertEqual(record.levelno, levelno)
|
||||||
self.assertEqual(record.status_code, status_code)
|
self.assertEqual(record.status_code, status_code)
|
||||||
self.assertEqual(record.request, request)
|
self.assertEqual(record.request, request)
|
||||||
|
return record
|
||||||
|
|
||||||
def test_missing_response_raises_attribute_error(self):
|
def test_missing_response_raises_attribute_error(self):
|
||||||
with self.assertRaises(AttributeError):
|
with self.assertRaises(AttributeError):
|
||||||
@ -806,3 +825,64 @@ class LogResponseRealLoggerTests(TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip()
|
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user