Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Precision

- A `TEMPLATES` assigned inside a function or a class no longer counts as Django settings: only a module-level assignment can activate the `request` context processor, and only a mention of the module's `TEMPLATES` elsewhere leaves that activation uncertain. A function-local `TEMPLATES`, called or not, used to enable the implicit request source, upgrading a template filter finding to exploitable (#184).
- A template block that renders `{{ block.super }}` keeps the parent block's content, so the filters the parent applies stay fed by the render context, with the parent template as the place of the call; a block without it still replaces the parent's whole block, and a chain of templates keeps exactly the parents each level renders. The parent's filters used to drop out of the exploitable flow as soon as a child defined the block (#185).

## 0.18.0 (2026-09-28)

### Precision
Expand Down
12 changes: 8 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ certain of it:
arguments of the filters before it, or the output of a `{% filter %}` block. A name a
tag binds shadows the context, and `{% include ... only %}` passes none of it.
- The filter renders: a template extending another renders only its blocks, which
replace the blocks of that name in what it extends.
replace the blocks of that name in what it extends, unless the block renders
`{{ block.super }}`, which keeps the parent's content and the filters it applies.
- One file has the template's name; of two files of the same name, which one renders
depends on the loaders.

Expand All @@ -399,9 +400,12 @@ With the `django.template.context_processors.request` context processor, a templ
rendered with the request reads it as `request`: `{{ request.GET.q|striptags }}` is
exploitable at the render call too. The analyzer counts it only where all of these hold:

- The project's settings assign `TEMPLATES` a literal list, every `DjangoTemplates`
engine of it lists the processor in a literal `context_processors`, and no other code
of the project names `TEMPLATES`. A mere mention of the processor proves nothing.
- The project's settings assign `TEMPLATES` a literal list at module level, every
`DjangoTemplates` engine of it lists the processor in a literal `context_processors`,
and no other code of the project names the module's `TEMPLATES`, or a `TEMPLATES` it
does not bind, as a star import gives. A `TEMPLATES` bound in a function or a class
is another name: it configures nothing and changes nothing.
A mere mention of the processor proves nothing.
- The render call passes the request: `render` and `TemplateResponse` always do,
`render_to_string` when given `request`, `SimpleTemplateResponse` never.
- The render's context is certain, absent or a dict literal as above, so it cannot hide
Expand Down
3 changes: 2 additions & 1 deletion src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ def analyze_project(
templates = project_templates(root)
clearing = models.clearing(escaped)
template_calls = models.template_calls(
templates.filters, request_processor(modules[name] for name in sorted(analysable))
templates.filters,
request_processor((modules[name], analysable[name].get(ScopeAnalysis)) for name in sorted(analysable)),
)
for manager in analysable.values():
manager.provide(RegisteredRoutes, routes)
Expand Down
4 changes: 2 additions & 2 deletions src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ def scope(self, scope_id: ScopeId) -> Scope:
def children(self, scope_id: ScopeId) -> tuple[Scope, ...]:
return self._children[scope_id]

def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope:
"""Return the scope introduced by a definition or comprehension node."""
def scope_for(self, node: nodes.Function | nodes.Class | nodes.Lambda | nodes.Comprehension) -> Scope:
"""Return the scope introduced by a definition, lambda or comprehension node."""

return self._scopes[self._spans[node.span]]

Expand Down
59 changes: 43 additions & 16 deletions src/coretrace_python/taint/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
TemplateFilter,
discover_files,
)
from coretrace_python.semantic.scopes import ResolutionKind, ScopeId, ScopeTable
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan
from coretrace_python.taint.models import ModelTable
Expand Down Expand Up @@ -131,6 +132,8 @@
# the request context processor adds gives (``request.GET``).
_VARIABLE = re.compile(r"([A-Za-z_]\w*)(?:\.(\w+))?")
_LITERALS = frozenset({"True", "False", "None"})
# The variable a block renders to keep the parent's content, as ``_chain`` reads it.
_BLOCK_SUPER = "block.super"

REQUEST_PROCESSOR = "django.template.context_processors.request"
_DJANGO_ENGINE = "django.template.backends.django.DjangoTemplates"
Expand Down Expand Up @@ -365,11 +368,13 @@ def maps(self, names: frozenset[str]) -> frozenset[str]:

@dataclass(frozen=True)
class _Template:
"""What one template file applies and references, and the ``blocks`` it defines."""
"""What one template file applies and references, the ``blocks`` it defines, and
among them the ``extended`` ones, which render ``{{ block.super }}``."""

applications: tuple[_Application, ...]
references: tuple[_Reference, ...]
blocks: frozenset[str]
extended: frozenset[str] = frozenset()

def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool:
"""Whether what stands in ``blocks`` renders when a template extending this one
Expand All @@ -379,6 +384,13 @@ def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool:
extends = any(reference.extends for reference in self.references)
return not overridden.intersection(blocks) and (bool(blocks) or not extends)

@property
def replaced(self) -> frozenset[str]:
"""The blocks this template replaces in what it extends: those it defines
without rendering ``{{ block.super }}``, which keeps the parent's content."""

return self.blocks - self.extended


def project_templates(root: Path) -> ProjectTemplates:
"""What the templates under ``root`` call, and the templates they include or extend
Expand Down Expand Up @@ -444,7 +456,7 @@ def _reaching(
if reference.name is None:
continue
if reference.extends:
found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.blocks)
found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.replaced)
elif template.rendered(reference.blocks, overridden):
found = _reaching(reference.name, files, read, visiting | {name})
else:
Expand All @@ -463,6 +475,7 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
references: list[_Reference] = []
opened: list[str] = []
defined: set[str] = set()
extended: set[str] = set()
scopes: list[dict[str, frozenset[str]]] = [{}]
blocks: list[tuple[list[tuple[str, int, frozenset[str]]], set[str]]] = []

Expand Down Expand Up @@ -491,6 +504,8 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) ->
for offset, expression, tag in _expressions(text):
if not tag:
head, chain = _chain(expression, offset)
if opened and _BLOCK_SUPER in head:
extended.add(opened[-1])
printed = apply(free(head), chain)
for _, collected in blocks:
collected |= printed
Expand Down Expand Up @@ -546,7 +561,7 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) ->
elif "as" in words[1:-1]:
target = words[words.index("as", 1) + 1]
scopes[-1][target] = values.get(words[1], frozenset()) if name == "regroup" else frozenset()
return _Template(tuple(applications), tuple(references), frozenset(defined))
return _Template(tuple(applications), tuple(references), frozenset(defined), frozenset(extended))


def _chain(expression: str, offset: int) -> tuple[frozenset[str], list[tuple[str, int, frozenset[str]]]]:
Expand Down Expand Up @@ -578,29 +593,41 @@ def _through(bound: frozenset[str], name: str) -> frozenset[str]:
return frozenset(".".join(f"{b}.{attribute}".split(".")[:2]) if attribute else b for b in bound)


def request_processor(modules: Iterable[nodes.Module]) -> bool:
def request_processor(modules: Iterable[tuple[nodes.Module, ScopeTable]]) -> bool:
"""Whether every Django template engine the project's settings configure runs the
``request`` context processor, for certain: each assignment to ``TEMPLATES`` is a
literal list of literal engines, at least one of them ``DjangoTemplates``, and each
of those lists the processor in a literal ``context_processors``; no other code
names ``TEMPLATES``, which could change it. A mere mention of the processor proves
nothing."""
``request`` context processor, for certain: each module-level assignment to
``TEMPLATES`` is a literal list of literal engines, at least one of them
``DjangoTemplates``, and each of those lists the processor in a literal
``context_processors``; no other code names the module's ``TEMPLATES``, or a
``TEMPLATES`` it does not bind (a star import), which could change it. A
``TEMPLATES`` bound in a function or a class is another name, which configures
nothing and changes nothing. A mere mention of the processor proves nothing."""

assignments = mentions = 0
for module in modules:
stack: list[Node] = list(module.body)
while stack:
node = stack.pop()
stack.extend(children(node))
for module, scopes in modules:
for node, scope in _scoped(module, scopes):
if isinstance(node, nodes.Name) and node.identifier == _ENGINES_SETTING:
mentions += 1
elif isinstance(node, nodes.Assign) and _names_engines(node.target):
if scopes.resolve(scope, node.identifier).kind in (ResolutionKind.GLOBAL, ResolutionKind.UNBOUND):
mentions += 1
elif isinstance(node, nodes.Assign) and _names_engines(node.target) and scope == scopes.module_scope.id:
if not _runs_request_processor(node.value):
return False
assignments += 1
return assignments > 0 and mentions == assignments


def _scoped(module: nodes.Module, scopes: ScopeTable) -> Iterator[tuple[Node, ScopeId]]:
"""Every node of ``module`` with the scope it stands in."""

stack: list[tuple[Node, ScopeId]] = [(node, scopes.module_scope.id) for node in module.body]
while stack:
node, scope = stack.pop()
yield node, scope
if isinstance(node, nodes.Function | nodes.Lambda | nodes.Class | nodes.Comprehension):
scope = scopes.scope_for(node).id
stack.extend((child, scope) for child in children(node))


def _names_engines(target: nodes.Expression) -> bool:
return isinstance(target, nodes.Name) and target.identifier == _ENGINES_SETTING

Expand Down
155 changes: 155 additions & 0 deletions tests/test_block_super.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""``{{ block.super }}`` keeps the parent block's filters in the render context flow.

A template extending another renders only its blocks, which replace the blocks of that
name in the parent. A block that renders ``{{ block.super }}`` extends the parent's
block instead of replacing it: Django executes the parent's content, so the filters it
applies stay fed by the render context, with the parent template as the place of the
call. A block without ``block.super`` still replaces the parent's whole block, other
overridden blocks stay excluded, and a chain of templates keeps exactly the parents each
level renders, without duplicate findings.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from coretrace_python import engine
from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories
from coretrace_python.findings import Severity
from coretrace_python.semantic.symbols import SymbolId

STRIPTAGS = "python.django.template.defaultfilters.striptags"
ADVISORY = Advisory(
"CVE-2099-5701",
"django",
"<4.2.17",
"striptags is quadratic in its input",
Severity.MEDIUM,
entry_points=(AdvisoryEntryPoint(SymbolId(STRIPTAGS), "a template filter"),),
)
LOCK = """version = 1

[[package]]
name = "app"
version = "0.1.0"
source = { virtual = "." }
dependencies = [{ name = "django" }]

[[package]]
name = "django"
version = "4.2.16"
source = { registry = "https://pypi.org/simple" }
"""
VIEW = (
"from django.http import HttpRequest\n"
"from django.shortcuts import render\n\n"
"def profile(request: HttpRequest):\n"
" return render(request, 'app/profile.html', {'bio': request.POST['bio']})\n"
)
LINE = 5
BASE = "app/templates/app/base.html"
MIDDLE = "app/templates/app/middle.html"
PAGE = "app/templates/app/profile.html"


def exploitable(root: Path, templates: dict[str, str]) -> list[tuple[int, str, str]]:
"""The exploitable striptags calls the render makes: the render line, the template
holding the filter, and the line of the filter there."""

files = {"uv.lock": LOCK, "app/__init__.py": "", "app/views.py": VIEW, **templates}
for relative, text in files.items():
(root / relative).parent.mkdir(parents=True, exist_ok=True)
(root / relative).write_text(text, encoding="utf-8")
(root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8")
findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS]).findings
return sorted(
(f.span.start_line, f.metadata["through"], f.metadata["sink_line"])
for f in findings
if f.rule_id == "exploitable-vulnerability" and f.metadata["advisory"] == ADVISORY.id
)


def test_a_block_rendering_block_super_keeps_the_parent_flow(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")]


def test_a_block_may_add_its_own_filters_beside_block_super(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
PAGE: "{% extends 'app/base.html' %}\n{% block body %}<p>{{ bio|striptags }}</p>\n{{ block.super }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/profile.html", "2")]


def test_a_replacement_without_block_super_still_excludes_the_parent(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ bio }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == []


def test_another_block_does_not_come_back_through_one_block_super(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n{% block aside %}{{ bio|striptags }}{% endblock %}\n",
PAGE: (
"{% extends 'app/base.html' %}\n"
"{% block body %}{{ block.super }}{% endblock %}\n"
"{% block aside %}{{ bio }}{% endblock %}\n"
),
}

assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")]


def test_block_super_at_each_level_keeps_the_whole_chain_once(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}<i>{{ bio|striptags }}</i>{% endblock %}\n",
PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/middle.html", "2")]


def test_a_replacement_in_the_middle_cuts_the_chain_above_it(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}<i>{{ bio|striptags }}</i>{% endblock %}\n",
PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == [(LINE, "app/middle.html", "2")]


def test_a_replacement_at_the_end_cuts_the_whole_chain(tmp_path: Path) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}<i>{{ bio|striptags }}</i>{% endblock %}\n",
PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ bio }}{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == []


@pytest.mark.parametrize(
"child_block",
["{{ block.super|lower }}", "{% if bio %}{{ block.super }}{% endif %}", "{{ block.super }}{{ block.super }}"],
ids=["filtered", "conditional", "twice"],
)
def test_block_super_counts_however_it_is_rendered(tmp_path: Path, child_block: str) -> None:
templates = {
BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n",
PAGE: "{% extends 'app/base.html' %}\n{% block body %}" + child_block + "{% endblock %}\n",
}

assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")]
Loading
Loading