Log Injection / Information Disclosure

HIGH
django/django
Commit: a07ebec5591e
Affected: Django 5.1.x series prior to this commit (stable/5.1.x; e.g., 5.1.0 - 5.1.9)
2026-04-05 14:02 UTC

Description

CVE-2025-48432: Escaped formatting arguments in log_response() to mitigate log injection via control characters in request.path. Previously, log_response logged formatting arguments (including request.path) without escaping, allowing CRLF sequences to be written into logs and potentially forge or disrupt log processing. The fix escapes all positional formatting arguments using unicode_escape before logging.

Proof of Concept

PoC steps (controlled environment): Start a Django 5.1.x app and send a request with a path that decodes to control characters in the log path. 1) Run: python manage.py runserver 8000 2) Attacker request: curl -s 'http://localhost:8000/attack%0D%0AINJECTION' 3) Observe logs. After the fix, the log entry will contain escaped control characters, e.g. Not Found: /attack\x0D\x0AINJECTION, instead of a raw newline. Before the fix, you could see a real newline in the log line separating the injected text. Alternative (Python requests): import requests requests.get("http://localhost:8000/attack%0D%0AINJECTION")

Commit Details

Author: Natalia

Date: 2025-05-20 18:29 UTC

Message:

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>

Triage Assessment

Vulnerability Type: Log Injection / Information Disclosure

Confidence: HIGH

Reasoning:

Commit directly addresses CVE-2025-48432 by escaping all positional formatting arguments in log_response(), mitigating log injection via CRLF/control characters in request paths. This is a security vulnerability fix (log injection risk).

Verification Assessment

Vulnerability Type: Log Injection / Information Disclosure

Confidence: HIGH

Affected Versions: Django 5.1.x series prior to this commit (stable/5.1.x; e.g., 5.1.0 - 5.1.9)

Code Diff

diff --git a/django/utils/log.py b/django/utils/log.py index a25b97a7d5a4..67a40270f076 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 83c49b787bb9..ba3cc332482f 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 7f2d4c2499d7..b5cc1f89a139 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 56efb69bfb54..556e5b3d5093 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 870a31948ceb..3e6e09b03d76 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -147,6 +147,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 +163,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/", @@ -705,6 +723,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): @@ -806,3 +825,64 @@ 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, + 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)
← Back to Alerts View on GitHub →