Repository: django/django

Issue:
# Refs #36769 -- Raised SuspiciousOperation for unexpected nested tags in XML Deserializer.

#### Trac ticket number
ticket-36769

#### Branch description
@shaib observed that raising `SuspiciousOperation` would be preferable to silently discarding data from invalid fixtures in https://github.com/django/django/pull/20377#discussion_r2616789793.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py
index d43865c25743..d8ffbdf00a98 100644
--- a/django/core/serializers/xml_serializer.py
+++ b/django/core/serializers/xml_serializer.py
@@ -10,7 +10,7 @@
 
 from django.apps import apps
 from django.conf import settings
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation
 from django.core.serializers import base
 from django.db import DEFAULT_DB_ALIAS, models
 from django.utils.xmlutils import SimplerXMLGenerator, UnserializableContentError
@@ -411,6 +411,8 @@ def m2m_convert(n):
         try:
             for c in node.getElementsByTagName("object"):
                 values.append(m2m_convert(c))
+        except SuspiciousOperation:
+            raise
         except Exception as e:
             if isinstance(e, ObjectDoesNotExist) and self.handle_forward_references:
                 return base.DEFER_FIELD
@@ -439,17 +441,15 @@ def _get_model_from_node(self, node, attr):
             )
 
 
+def check_element_type(element):
+    if element.childNodes:
+        raise SuspiciousOperation(f"Unexpected element: {element.tagName!r}")
+    return element.nodeType in (element.TEXT_NODE, element.CDATA_SECTION_NODE)
+
+
 def getInnerText(node):
-    """Get the inner text of a DOM node and any children one level deep."""
-    # inspired by
-    # https://mail.python.org/pipermail/xml-sig/2005-March/011022.html
     return "".join(
-        [
-            element.data
-            for child in node.childNodes
-            for element in (child, *child.childNodes)
-            if element.nodeType in (element.TEXT_NODE, element.CDATA_SECTION_NODE)
-        ]
+        [child.data for child in node.childNodes if check_element_type(child)]
     )
 
 


Test patch:
diff --git a/tests/fixtures/tests.py b/tests/fixtures/tests.py
index 7511569d2102..dd03343027f3 100644
--- a/tests/fixtures/tests.py
+++ b/tests/fixtures/tests.py
@@ -10,6 +10,7 @@
 from django.apps import apps
 from django.contrib.sites.models import Site
 from django.core import management
+from django.core.exceptions import SuspiciousOperation
 from django.core.files.temp import NamedTemporaryFile
 from django.core.management import CommandError
 from django.core.management.commands.dumpdata import ProxyModelWarning
@@ -23,7 +24,6 @@
     CircularA,
     CircularB,
     NaturalKeyThing,
-    Person,
     PrimaryKeyUUIDModel,
     ProxySpy,
     Spy,
@@ -522,12 +522,13 @@ def test_loading_and_dumping(self):
         )
 
     def test_deeply_nested_elements(self):
-        """Text inside deeply-nested tags is skipped."""
-        management.call_command(
-            "loaddata", "invalid_deeply_nested_elements.xml", verbosity=0
-        )
-        person = Person.objects.get(pk=1)
-        self.assertEqual(person.name, "Django")  # not "Django pony"
+        """Text inside deeply-nested tags raises SuspiciousOperation."""
+        for file in [
+            "invalid_deeply_nested_elements.xml",
+            "invalid_deeply_nested_elements_natural_key.xml",
+        ]:
+            with self.subTest(file=file), self.assertRaises(SuspiciousOperation):
+                management.call_command("loaddata", file, verbosity=0)
 
     def test_dumpdata_with_excludes(self):
         # Load fixture1 which has a site, two articles, and a category
diff --git a/tests/serializers/test_deserialization.py b/tests/serializers/test_deserialization.py
index a718a990385a..b6a0818c427a 100644
--- a/tests/serializers/test_deserialization.py
+++ b/tests/serializers/test_deserialization.py
@@ -1,15 +1,14 @@
 import json
-import time
+import textwrap
 import unittest
 
+from django.core.exceptions import SuspiciousOperation
 from django.core.serializers.base import DeserializationError, DeserializedObject
 from django.core.serializers.json import Deserializer as JsonDeserializer
 from django.core.serializers.jsonl import Deserializer as JsonlDeserializer
 from django.core.serializers.python import Deserializer
 from django.core.serializers.xml_serializer import Deserializer as XMLDeserializer
-from django.db import models
 from django.test import SimpleTestCase
-from django.test.utils import garbage_collect
 
 from .models import Author
 
@@ -138,52 +137,23 @@ def test_yaml_bytes_input(self):
         self.assertEqual(first_item.object, self.jane)
         self.assertEqual(second_item.object, self.joe)
 
-    def test_crafted_xml_performance(self):
-        """The time to process invalid inputs is not quadratic."""
-
-        def build_crafted_xml(depth, leaf_text_len):
-            nested_open = "<nested>" * depth
-            nested_close = "</nested>" * depth
-            leaf = "x" * leaf_text_len
-            field_content = f"{nested_open}{leaf}{nested_close}"
-            return f"""
-                <django-objects version="1.0">
-                   <object model="contenttypes.contenttype" pk="1">
-                      <field name="app_label">{field_content}</field>
-                      <field name="model">m</field>
-                   </object>
-                </django-objects>
-            """
-
-        def deserialize(crafted_xml):
-            iterator = XMLDeserializer(crafted_xml)
-            garbage_collect()
-
-            start_time = time.perf_counter()
-            result = list(iterator)
-            end_time = time.perf_counter()
-
-            self.assertEqual(len(result), 1)
-            self.assertIsInstance(result[0].object, models.Model)
-            return end_time - start_time
-
-        def assertFactor(label, params, factor=2):
-            factors = []
-            prev_time = None
-            for depth, length in params:
-                crafted_xml = build_crafted_xml(depth, length)
-                elapsed = deserialize(crafted_xml)
-                if prev_time is not None:
-                    factors.append(elapsed / prev_time)
-                prev_time = elapsed
-
-            with self.subTest(label):
-                # Assert based on the average factor to reduce test flakiness.
-                self.assertLessEqual(sum(factors) / len(factors), factor)
-
-        assertFactor(
-            "varying depth, varying length",
-            [(50, 2000), (100, 4000), (200, 8000), (400, 16000), (800, 32000)],
-            2,
+    def test_crafted_xml_rejected(self):
+        depth = 100
+        leaf_text_len = 1000
+        nested_open = "<nested>" * depth
+        nested_close = "</nested>" * depth
+        leaf = "x" * leaf_text_len
+        field_content = f"{nested_open}{leaf}{nested_close}"
+        crafted_xml = textwrap.dedent(
+            f"""
+        <django-objects version="1.0">
+            <object model="contenttypes.contenttype" pk="1">
+                <field name="app_label">{field_content}</field>
+                <field name="model">m</field>
+            </object>
+        </django-objects>"""
         )
-        assertFactor("constant depth, varying length", [(100, 1), (100, 1000)], 2)
+
+        msg = "Unexpected element: 'nested'"
+        with self.assertRaisesMessage(SuspiciousOperation, msg):
+            list(XMLDeserializer(crafted_xml))