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

## 0.18.0 (2026-09-28)

### Precision
Expand Down
9 changes: 6 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,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
39 changes: 26 additions & 13 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 @@ -578,29 +579,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
109 changes: 109 additions & 0 deletions tests/test_settings_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Only module-level settings configure Django.

The request context processor is taken as active only from the settings a project
assigns at module level: a ``TEMPLATES`` assigned inside a function, called or not,
binds a local name and configures nothing, and so does a class attribute. A function
that reads or rebinds the module's ``TEMPLATES`` (through ``global``, or by mutating
it) may change the effective settings, so it leaves the activation uncertain, as any
other mention does. Uncertain activation leaves the filter reachable, neither
exploitable through the processor nor declared absent.
"""

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-5601",
"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" }
"""
ENGINE = (
"{'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, "
"'OPTIONS': {'context_processors': ['django.template.context_processors.request']}}"
)
VIEW = (
"from django.http import HttpRequest\n"
"from django.shortcuts import render\n\n"
"def search(request: HttpRequest):\n"
" return render(request, 'app/search.html')\n"
)
PAGE = "app/templates/app/search.html"


def levels(root: Path, settings: str) -> list[str]:
files = {
"uv.lock": LOCK,
"app/__init__.py": "",
"app/settings.py": settings,
"app/views.py": VIEW,
PAGE: "{{ request.GET.q|striptags }}\n",
}
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
reached = ("reachable-vulnerability", "exploitable-vulnerability")
return sorted(f.metadata["level"] for f in findings if f.rule_id in reached and f.metadata["advisory"] == ADVISORY.id)


def test_module_level_settings_activate_the_processor(tmp_path: Path) -> None:
assert levels(tmp_path, f"TEMPLATES = [{ENGINE}]\n") == ["exploitable", "reachable"]


@pytest.mark.parametrize(
"settings",
[
f"def never_called():\n TEMPLATES = [{ENGINE}]\n",
f"def configure():\n TEMPLATES = [{ENGINE}]\n return TEMPLATES\n\nconfigure()\n",
f"class Settings:\n TEMPLATES = [{ENGINE}]\n",
f"def build():\n templates = [{ENGINE}]\n return templates\n",
],
ids=["uncalled-function", "called-function", "class-attribute", "other-local-name"],
)
def test_settings_bound_below_the_module_do_not_configure_django(tmp_path: Path, settings: str) -> None:
assert levels(tmp_path, settings) == ["reachable"]


@pytest.mark.parametrize(
"settings",
[
f"TEMPLATES = [{ENGINE}]\n\ndef tweak():\n TEMPLATES[0]['OPTIONS']['context_processors'].pop(0)\n",
f"TEMPLATES = [{ENGINE}]\n\ndef reset():\n global TEMPLATES\n TEMPLATES = []\n",
f"TEMPLATES = [{ENGINE}]\n\ndef read():\n return TEMPLATES\n",
],
ids=["mutated-in-a-function", "rebound-through-global", "read-in-a-function"],
)
def test_a_function_touching_the_module_settings_leaves_activation_uncertain(tmp_path: Path, settings: str) -> None:
assert levels(tmp_path, settings) == ["reachable"]


def test_a_local_name_beside_module_settings_changes_nothing(tmp_path: Path) -> None:
settings = f"TEMPLATES = [{ENGINE}]\n\ndef helper():\n TEMPLATES = []\n return TEMPLATES\n"

assert levels(tmp_path, settings) == ["exploitable", "reachable"]
Loading