Repository: django/django
Issue category: Bug Report

Allow `./` and `../` in paths when recursively including templates
Description
		
(last modified by Gabriel Nick Pivovarov)		
Hi. Currently, when trying to recursively include a Django template within itself using the include tag with a path that contains ./ or ../, Django raises a TemplateSyntaxError. However, using a path that does not contain ./ or ../ does not raise the error. When the error is raised, the debug toolbar describes it like this:
TemplateSyntaxError at /
The relative path ‘“./ul.html”’ was translated to template name ‘app/ul.html’, the same template in which the tag appears.
Here is an example of a template in a Django app called app with the path app/templates/app/ul.html that would produce the error given above:
<ul>
	{% for section in sections %}
		<li>
			<p>{{ section.name }}</p>
			{% if section.sections|length != 0 %}
				{% include "./ul.html" with sections=section.sections %}
			{% endif %}
		</li>
	{% endfor %}
</ul>
However, replacing the directory ./ul.html with the equivalent app/ul.html makes the error go away (assuming that the project's settings.py specifies APP_DIRS = True and the views and URLs are configured correctly). The actual paths are translated identically in both cases, and the behavior of the include tag should not depend simply on whether or not the path string uses ./ or ../ (or if it should, this is not documented in the Django documentation). Therefore, it seems that this is a bug. The expected behavior is that an error is only raised when recursively using the extends template, not when recursively using the include template.
Contrapositively, it appears that recursively extending a template using the extends tag with a path that does not contain ./ or ../ raises a TemplateDoesNotExist exception.
One possible fix is to modify the django/template/loader_tags.py file (​https://github.com/django/django/blob/main/django/template/loader_tags.py) such that the error is raised when a template attempts to extend itself (not when a template attempts to include itself, which would otherwise be valid). The error handling logic in question starts on line 267 of that file within the construct_relative_path function; perhaps it should only be used when called from the do_extends function.
Here is a relevant discussion in the Django forums: ​https://forum.djangoproject.com/t/template-recursion-why-does-django-not-allow-and/31689


Functions to edit:
django/template/loader_tags.py:construct_relative_path
django/template/loader_tags.py:do_include

Patch:
diff --git a/django/template/loader_tags.py b/django/template/loader_tags.py
index 1874d8c52881..36703b47823a 100644
--- a/django/template/loader_tags.py
+++ b/django/template/loader_tags.py
@@ -242,7 +242,11 @@ def do_block(parser, token):
     return BlockNode(block_name, nodelist)
 
 
-def construct_relative_path(current_template_name, relative_name):
+def construct_relative_path(
+    current_template_name,
+    relative_name,
+    allow_recursion=False,
+):
     """
     Convert a relative path (starting with './' or '../') to the full template
     name based on the current_template_name.
@@ -264,7 +268,7 @@ def construct_relative_path(current_template_name, relative_name):
             "The relative path '%s' points outside the file hierarchy that "
             "template '%s' is in." % (relative_name, current_template_name)
         )
-    if current_template_name.lstrip("/") == new_name:
+    if not allow_recursion and current_template_name.lstrip("/") == new_name:
         raise TemplateSyntaxError(
             "The relative path '%s' was translated to template name '%s', the "
             "same template in which the tag appears."
@@ -346,7 +350,11 @@ def do_include(parser, token):
         options[option] = value
     isolated_context = options.get("only", False)
     namemap = options.get("with", {})
-    bits[1] = construct_relative_path(parser.origin.template_name, bits[1])
+    bits[1] = construct_relative_path(
+        parser.origin.template_name,
+        bits[1],
+        allow_recursion=True,
+    )
     return IncludeNode(
         parser.compile_filter(bits[1]),
         extra_context=namemap,

Test patch: diff --git a/tests/template_tests/syntax_tests/test_include.py b/tests/template_tests/syntax_tests/test_include.py
index 3ee99b37981e..be0deee9260d 100644
--- a/tests/template_tests/syntax_tests/test_include.py
+++ b/tests/template_tests/syntax_tests/test_include.py
@@ -330,15 +330,43 @@ def test_include_recursive(self):
                 ],
             }
         ]
-        engine = Engine(app_dirs=True)
-        t = engine.get_template("recursive_include.html")
-        self.assertEqual(
-            "Recursion!  A1  Recursion!  B1   B2   B3  Recursion!  C1",
-            t.render(Context({"comments": comments}))
-            .replace(" ", "")
-            .replace("\n", " ")
-            .strip(),
-        )
+        with self.subTest(template="recursive_include.html"):
+            engine = Engine(app_dirs=True)
+            t = engine.get_template("recursive_include.html")
+            self.assertEqual(
+                "Recursion!  A1  Recursion!  B1   B2   B3  Recursion!  C1",
+                t.render(Context({"comments": comments}))
+                .replace(" ", "")
+                .replace("\n", " ")
+                .strip(),
+            )
+        with self.subTest(template="recursive_relative_include.html"):
+            engine = Engine(app_dirs=True)
+            t = engine.get_template("recursive_relative_include.html")
+            self.assertEqual(
+                "Recursion!  A1  Recursion!  B1   B2   B3  Recursion!  C1",
+                t.render(Context({"comments": comments}))
+                .replace(" ", "")
+                .replace("\n", " ")
+                .strip(),
+            )
+        with self.subTest(template="tmpl"):
+            engine = Engine()
+            template = """
+            Recursion!
+            {% for c in comments %}
+              {{ c.comment }}
+              {% if c.children %}{% include tmpl with comments=c.children %}{% endif %}
+            {% endfor %}
+            """
+            outer_tmpl = engine.from_string("{% include tmpl %}")
+            output = outer_tmpl.render(
+                Context({"tmpl": engine.from_string(template), "comments": comments})
+            )
+            self.assertEqual(
+                "Recursion!  A1  Recursion!  B1   B2   B3  Recursion!  C1",
+                output.replace(" ", "").replace("\n", " ").strip(),
+            )
 
     def test_include_cache(self):
         """
diff --git a/tests/template_tests/templates/recursive_relative_include.html b/tests/template_tests/templates/recursive_relative_include.html
new file mode 100644
index 000000000000..ae49cc0a4367
--- /dev/null
+++ b/tests/template_tests/templates/recursive_relative_include.html
@@ -0,0 +1,7 @@
+Recursion!
+{% for comment in comments %}
+    {{ comment.comment }}
+    {% if comment.children %}
+        {% include "./recursive_relative_include.html" with comments=comment.children %}
+    {% endif %}
+{% endfor %}