From e9cb7c92fef2e0ee92d0b01cd87620e7c0d99e56 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:12:41 +0200 Subject: [PATCH 1/2] test: specify that only module-level settings activate the request processor A TEMPLATES bound in a function, called or not, or in a class configures nothing; a function reading, mutating or rebinding the module's TEMPLATES leaves the activation uncertain; a local TEMPLATES beside module settings changes nothing. --- tests/test_settings_scope.py | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_settings_scope.py diff --git a/tests/test_settings_scope.py b/tests/test_settings_scope.py new file mode 100644 index 0000000..0a1ec5b --- /dev/null +++ b/tests/test_settings_scope.py @@ -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"] From b2e1f231fe5b50dc5891b6a8e59279a80c52ba35 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Sep 2026 00:12:41 +0200 Subject: [PATCH 2/2] fix: take only module-level TEMPLATES as Django settings request_processor walked every node of every module and counted any assignment to TEMPLATES, so a function-local or class-level assignment activated the request context processor. It now walks the modules with their scope tables: an assignment counts only at module scope, and a mention counts only when the name resolves to the module's binding, or to nothing, as a star import gives. ScopeTable.scope_for accepts a lambda, whose scope the builder already records. --- CHANGELOG.md | 6 ++++ docs/usage.md | 9 ++++-- src/coretrace_python/engine.py | 3 +- src/coretrace_python/semantic/scopes.py | 4 +-- src/coretrace_python/taint/templates.py | 39 ++++++++++++++++--------- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f04f2..3adce12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index 35c8e90..fce946f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index b877096..2d70a21 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -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) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 9f4a3b2..4120871 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -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]] diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index a41ed57..1c56df1 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -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 @@ -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