Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# 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).

### Plugins

- An `EntryPoint` naming a project function or class applies through a module-level alias of it: `main = actual` makes `python.app.handlers.main` name `actual`, so a deployment declaring `handlers.main` reaches the function doing the work. An alias is an assignment of one name to another at module level, followed transitively and in source order, in a conditional or a `try` as well; a name bound inside a function or class, or to a call, an import or anything but a name, is no alias, and the engine still infers nothing from a name. A model naming a method wins over one naming its class, whichever name it uses.

## 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
57 changes: 50 additions & 7 deletions src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,20 +836,26 @@ def function_entry_point(
) -> EntryPoint | None:
"""The entry point ``function`` is: one of its decorators or, for a method, one of its
class's bases matching an entry-point model, a registration elsewhere (``routes``), or
a model naming the function, or its class, by its own project symbol."""
a model naming the function, or its class, by its own project symbol or by a
module-level alias of it (``main = actual`` names ``actual`` as ``main``). A model
naming the method wins over one naming its class, whichever name it uses."""

owner = _owner(module, function)
enclosing_function = _enclosing(module, function)
if enclosing_function is not None:
# ``app = Flask(__name__)`` inside ``create_app``: routes defined there resolve.
instances = {**(instances or {}), **local_instances(enclosing_function, scopes, symbols)}
entry = entry_point_of(function, models, scopes, symbols, owner, instances, project)
if entry is None:
qualified = function.name if owner is None else f"{owner.name}.{function.name}"
entry = _declared(project_symbol(module.name, qualified), models, routes)
if entry is None and owner is not None:
entry = _declared(project_symbol(module.name, owner.name), models, routes)
return entry
if entry is not None:
return entry
defined = function if owner is None else owner
names = (defined.name, *_aliases(module, defined.name))
qualified = names if owner is None else (*(f"{name}.{function.name}" for name in names), *names)
for name in qualified:
entry = _declared(project_symbol(module.name, name), models, routes)
if entry is not None:
return entry
return None


def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> EntryPoint | None:
Expand All @@ -860,6 +866,43 @@ def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> En
return registered if registered is not None else models.entry_point(symbol)


def _aliases(module: nodes.Module, name: str) -> Iterable[str]:
"""The module-level names bound to ``name`` by assigning one name to another,
transitively and in source order: ``handler = actual`` then ``main = handler`` make
both aliases of ``actual``. A name bound inside a function or class, or to anything
but a name (a call, an import), is no alias."""

aliases: dict[str, list[str]] = {}
for statement in _module_level(module.body):
if isinstance(statement, nodes.Assign) and isinstance(statement.target, nodes.Name) and isinstance(statement.value, nodes.Name):
aliases.setdefault(statement.value.identifier, []).append(statement.target.identifier)
found = {name}
pending = [name]
while pending:
for alias in aliases.get(pending.pop(0), ()):
if alias not in found:
found.add(alias)
pending.append(alias)
yield alias


def _module_level(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]:
"""The statements executed at module level: the body and what its compound
statements hold, not what a function or class defines."""

for statement in body:
if isinstance(statement, nodes.Function | nodes.Class):
continue
yield statement
for attribute in ("body", "orelse", "finalbody"):
nested = getattr(statement, attribute, None)
if isinstance(nested, tuple):
yield from _module_level(nested)
if isinstance(statement, nodes.Try):
for handler in statement.handlers:
yield from _module_level(handler.body)


def _owner(module: nodes.Module, function: nodes.Function) -> nodes.Class | None:
"""The top-level class ``function`` is a method of, if any."""

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
Loading
Loading