diff --git a/django/__init__.py b/django/__init__.py index 6954a553447f..45efb4d5d76a 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (5, 0, 9, "final", 0) +VERSION = (5, 0, 10, "final", 0) __version__ = get_version(VERSION) diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index b7cde157c4fa..b9c6ff1752b9 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -216,20 +216,18 @@ def compile_json_path_final_key(self, key_transform): # Compile the final key without interpreting ints as array elements. return ".%s" % json.dumps(key_transform) - def as_sql(self, compiler, connection, template=None): + def _as_sql_parts(self, compiler, connection): # Process JSON path from the left-hand side. if isinstance(self.lhs, KeyTransform): - lhs, lhs_params, lhs_key_transforms = self.lhs.preprocess_lhs( + lhs_sql, lhs_params, lhs_key_transforms = self.lhs.preprocess_lhs( compiler, connection ) lhs_json_path = compile_json_path(lhs_key_transforms) else: - lhs, lhs_params = self.process_lhs(compiler, connection) + lhs_sql, lhs_params = self.process_lhs(compiler, connection) lhs_json_path = "$" - sql = template % lhs # Process JSON path from the right-hand side. rhs = self.rhs - rhs_params = [] if not isinstance(rhs, (list, tuple)): rhs = [rhs] for key in rhs: @@ -240,24 +238,43 @@ def as_sql(self, compiler, connection, template=None): *rhs_key_transforms, final_key = rhs_key_transforms rhs_json_path = compile_json_path(rhs_key_transforms, include_root=False) rhs_json_path += self.compile_json_path_final_key(final_key) - rhs_params.append(lhs_json_path + rhs_json_path) + yield lhs_sql, lhs_params, lhs_json_path + rhs_json_path + + def _combine_sql_parts(self, parts): # Add condition for each key. if self.logical_operator: - sql = "(%s)" % self.logical_operator.join([sql] * len(rhs_params)) - return sql, tuple(lhs_params) + tuple(rhs_params) + return "(%s)" % self.logical_operator.join(parts) + return "".join(parts) + + def as_sql(self, compiler, connection, template=None): + sql_parts = [] + params = [] + for lhs_sql, lhs_params, rhs_json_path in self._as_sql_parts( + compiler, connection + ): + sql_parts.append(template % (lhs_sql, "%s")) + params.extend(lhs_params + [rhs_json_path]) + return self._combine_sql_parts(sql_parts), tuple(params) def as_mysql(self, compiler, connection): return self.as_sql( - compiler, connection, template="JSON_CONTAINS_PATH(%s, 'one', %%s)" + compiler, connection, template="JSON_CONTAINS_PATH(%s, 'one', %s)" ) def as_oracle(self, compiler, connection): - sql, params = self.as_sql( - compiler, connection, template="JSON_EXISTS(%s, '%%s')" - ) - # Add paths directly into SQL because path expressions cannot be passed - # as bind variables on Oracle. - return sql % tuple(params), [] + template = "JSON_EXISTS(%s, '%s')" + sql_parts = [] + params = [] + for lhs_sql, lhs_params, rhs_json_path in self._as_sql_parts( + compiler, connection + ): + # Add right-hand-side directly into SQL because it cannot be passed + # as bind variables to JSON_EXISTS. It might result in invalid + # queries but it is assumed that it cannot be evaded because the + # path is JSON serialized. + sql_parts.append(template % (lhs_sql, rhs_json_path)) + params.extend(lhs_params) + return self._combine_sql_parts(sql_parts), tuple(params) def as_postgresql(self, compiler, connection): if isinstance(self.rhs, KeyTransform): @@ -269,7 +286,7 @@ def as_postgresql(self, compiler, connection): def as_sqlite(self, compiler, connection): return self.as_sql( - compiler, connection, template="JSON_TYPE(%s, %%s) IS NOT NULL" + compiler, connection, template="JSON_TYPE(%s, %s) IS NOT NULL" ) @@ -467,9 +484,9 @@ def as_oracle(self, compiler, connection): return "(NOT %s OR %s IS NULL)" % (sql, lhs), tuple(params) + tuple(lhs_params) def as_sqlite(self, compiler, connection): - template = "JSON_TYPE(%s, %%s) IS NULL" + template = "JSON_TYPE(%s, %s) IS NULL" if not self.rhs: - template = "JSON_TYPE(%s, %%s) IS NOT NULL" + template = "JSON_TYPE(%s, %s) IS NOT NULL" return HasKeyOrArrayIndex(self.lhs.lhs, self.lhs.key_name).as_sql( compiler, connection, diff --git a/django/utils/html.py b/django/utils/html.py index ac444cf8ee8a..d04e594d13a1 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -7,6 +7,7 @@ from html.parser import HTMLParser from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit +from django.core.exceptions import SuspiciousOperation from django.utils.deprecation import RemovedInDjango60Warning from django.utils.encoding import punycode from django.utils.functional import Promise, cached_property, keep_lazy, keep_lazy_text @@ -37,6 +38,7 @@ } MAX_URL_LENGTH = 2048 +MAX_STRIP_TAGS_DEPTH = 50 @keep_lazy(SafeString) @@ -202,15 +204,19 @@ def _strip_once(value): @keep_lazy_text def strip_tags(value): """Return the given HTML with all tags stripped.""" - # Note: in typical case this loop executes _strip_once once. Loop condition - # is redundant, but helps to reduce number of executions of _strip_once. value = str(value) + # Note: in typical case this loop executes _strip_once twice (the second + # execution does not remove any more tags). + strip_tags_depth = 0 while "<" in value and ">" in value: + if strip_tags_depth >= MAX_STRIP_TAGS_DEPTH: + raise SuspiciousOperation new_value = _strip_once(value) if value.count("<") == new_value.count("<"): # _strip_once wasn't able to detect more tags. break value = new_value + strip_tags_depth += 1 return value diff --git a/docs/conf.py b/docs/conf.py index 728e74e52c12..2ae4e5bd4511 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,8 @@ import sys from os.path import abspath, dirname, join +from sphinx import version_info as sphinx_version + # Workaround for sphinx-build recursion limit overflow: # pickle.dump(doctree, f, pickle.HIGHEST_PROTOCOL) # RuntimeError: maximum recursion depth exceeded while pickling an object @@ -133,13 +135,15 @@ def django_release(): extlinks = { "bpo": ("https://bugs.python.org/issue?@action=redirect&bpo=%s", "bpo-%s"), "commit": ("https://github.com/django/django/commit/%s", "%s"), - "cve": ("https://nvd.nist.gov/vuln/detail/CVE-%s", "CVE-%s"), "pypi": ("https://pypi.org/project/%s/", "%s"), # A file or directory. GitHub redirects from blob to tree if needed. "source": ("https://github.com/django/django/blob/main/%s", "%s"), "ticket": ("https://code.djangoproject.com/ticket/%s", "#%s"), } +if sphinx_version < (8, 1): + extlinks["cve"] = ("https://www.cve.org/CVERecord?id=CVE-%s", "CVE-%s") + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None diff --git a/docs/releases/4.2.17.txt b/docs/releases/4.2.17.txt new file mode 100644 index 000000000000..9a6aee3db6ef --- /dev/null +++ b/docs/releases/4.2.17.txt @@ -0,0 +1,33 @@ +=========================== +Django 4.2.17 release notes +=========================== + +*December 4, 2024* + +Django 4.2.17 fixes one security issue with severity "high" and one security +issue with severity "moderate" in 4.2.16. + +CVE-2024-53907: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be extremely slow to evaluate +certain inputs containing large sequences of nested incomplete HTML entities. +The ``strip_tags()`` method is used to implement the corresponding +:tfilter:`striptags` template filter, which was thus also vulnerable. + +``strip_tags()`` now has an upper limit of recursive calls to ``HTMLParser`` +before raising a :exc:`.SuspiciousOperation` exception. + +Remember that absolutely NO guarantee is provided about the results of +``strip_tags()`` being HTML safe. So NEVER mark safe the result of a +``strip_tags()`` call without escaping it first, for example with +:func:`django.utils.html.escape`. + +CVE-2024-53908: Potential SQL injection via ``HasKey(lhs, rhs)`` on Oracle +========================================================================== + +Direct usage of the ``django.db.models.fields.json.HasKey`` lookup on Oracle +was subject to SQL injection if untrusted data was used as a ``lhs`` value. + +Applications that use the :lookup:`has_key ` lookup through +the ``__`` syntax are unaffected. diff --git a/docs/releases/5.0.10.txt b/docs/releases/5.0.10.txt new file mode 100644 index 000000000000..ae1fbf99e40a --- /dev/null +++ b/docs/releases/5.0.10.txt @@ -0,0 +1,33 @@ +=========================== +Django 5.0.10 release notes +=========================== + +*December 4, 2024* + +Django 5.0.10 fixes one security issue with severity "high" and one security +issue with severity "moderate" in 5.0.9. + +CVE-2024-53907: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be extremely slow to evaluate +certain inputs containing large sequences of nested incomplete HTML entities. +The ``strip_tags()`` method is used to implement the corresponding +:tfilter:`striptags` template filter, which was thus also vulnerable. + +``strip_tags()`` now has an upper limit of recursive calls to ``HTMLParser`` +before raising a :exc:`.SuspiciousOperation` exception. + +Remember that absolutely NO guarantee is provided about the results of +``strip_tags()`` being HTML safe. So NEVER mark safe the result of a +``strip_tags()`` call without escaping it first, for example with +:func:`django.utils.html.escape`. + +CVE-2024-53908: Potential SQL injection via ``HasKey(lhs, rhs)`` on Oracle +========================================================================== + +Direct usage of the ``django.db.models.fields.json.HasKey`` lookup on Oracle +was subject to SQL injection if untrusted data was used as a ``lhs`` value. + +Applications that use the :lookup:`has_key ` lookup through +the ``__`` syntax are unaffected. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index f4be3f453d50..8308705f3a17 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 5.0.10 5.0.9 5.0.8 5.0.7 @@ -42,6 +43,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.17 4.2.16 4.2.15 4.2.14 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 5d2c3900f508..c99953a81bf6 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,28 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +September 3, 2024 - :cve:`2024-45231` +------------------------------------- + +Potential user email enumeration via response status on password reset. +`Full description +`__ + +* Django 5.1 :commit:`(patch) <3c733c78d6f8e50296d6e248968b6516c92a53ca>` +* Django 5.0 :commit:`(patch) <96d84047715ea1715b4bd1594e46122b8a77b9e2>` +* Django 4.2 :commit:`(patch) ` + +September 3, 2024 - :cve:`2024-45230` +------------------------------------- + +Potential denial-of-service vulnerability in ``django.utils.html.urlize()``. +`Full description +`__ + +* Django 5.1 :commit:`(patch) <022ab0a75c76ab2ea31dfcc5f2cf5501e378d397>` +* Django 5.0 :commit:`(patch) <813de2672bd7361e9a453ab62cd6e52f96b6525b>` +* Django 4.2 :commit:`(patch) ` + August 6, 2024 - :cve:`2024-42005` ---------------------------------- diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py index 63e06b99cb50..eb129370fbdc 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -29,6 +29,7 @@ from django.db.models.expressions import RawSQL from django.db.models.fields.json import ( KT, + HasKey, KeyTextTransform, KeyTransform, KeyTransformFactory, @@ -607,6 +608,14 @@ def test_has_key_deep(self): [expected], ) + def test_has_key_literal_lookup(self): + self.assertSequenceEqual( + NullableJSONModel.objects.filter( + HasKey(Value({"foo": "bar"}, JSONField()), "foo") + ).order_by("id"), + self.objs, + ) + def test_has_key_list(self): obj = NullableJSONModel.objects.create(value=[{"a": 1}, {"b": "x"}]) tests = [ diff --git a/tests/user_commands/tests.py b/tests/user_commands/tests.py index 65e176620db1..2a1e904f3bda 100644 --- a/tests/user_commands/tests.py +++ b/tests/user_commands/tests.py @@ -400,8 +400,8 @@ def test_subparser_dest_required_args(self): self.assertIn("bar", out.getvalue()) def test_subparser_invalid_option(self): - msg = "invalid choice: 'test' (choose from 'foo')" - with self.assertRaisesMessage(CommandError, msg): + msg = r"invalid choice: 'test' \(choose from '?foo'?\)" + with self.assertRaisesRegex(CommandError, msg): management.call_command("subparser", "test", 12) msg = "Error: the following arguments are required: subcommand" with self.assertRaisesMessage(CommandError, msg): diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index 035585ec1016..e7efe0e5687b 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -1,6 +1,7 @@ import os from datetime import datetime +from django.core.exceptions import SuspiciousOperation from django.core.serializers.json import DjangoJSONEncoder from django.test import SimpleTestCase from django.utils.deprecation import RemovedInDjango60Warning @@ -123,12 +124,18 @@ def test_strip_tags(self): ("&h", "alert()h"), (">br>br>br>X", "XX"), + ("<" * 50 + "a>" * 50, ""), ) for value, output in items: with self.subTest(value=value, output=output): self.check_output(strip_tags, value, output) self.check_output(strip_tags, lazystr(value), output) + def test_strip_tags_suspicious_operation(self): + value = "<" * 51 + "a>" * 51, "" + with self.assertRaises(SuspiciousOperation): + strip_tags(value) + def test_strip_tags_files(self): # Test with more lengthy content (also catching performance regressions) for filename in ("strip_tags1.html", "strip_tags2.txt"):