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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

### Advisories

- A filter of Django's own libraries that a project template applies is a call to the function behind it, at the template's line: `{{ bio|striptags }}` calls `django.template.defaultfilters.striptags`, and so do filters in tag arguments and `{% filter %}` blocks. An advisory entry point naming that function is reachable there, whether or not a Python call names the template, since a class-based view renders its `template_name` inside Django. The data a template passes to a filter is not tracked yet, so such a call is reachable, not exploitable. A template the analyzer cannot read may apply any filter: when the project names one by an expression, or by a name found under no `templates` directory, in `render`, `render_to_string`, `TemplateResponse`, `{% include %}` or `{% extends %}`, an advisory whose entry points include a template filter stays `under_investigation` in the VEX document, and the notes say where. `render`, `TemplateResponse` and `SimpleTemplateResponse` are now template renders like `render_to_string` (#146).
- A filter of Django's own libraries that a project template applies is a call to the function behind it, at the template's line: `{{ bio|striptags }}` calls `django.template.defaultfilters.striptags`, and so do filters in tag arguments and `{% filter %}` blocks. An advisory entry point naming that function is reachable there, whether or not a Python call names the template, since a class-based view renders its `template_name` inside Django. A template the analyzer cannot read may apply any filter: when the project names one by an expression, or by a name found under no `templates` directory, in `render`, `render_to_string`, `TemplateResponse`, `{% include %}` or `{% extends %}`, an advisory whose entry points include a template filter stays `under_investigation` in the VEX document, and the notes say where. `render`, `TemplateResponse` and `SimpleTemplateResponse` are now template renders like `render_to_string` (#146).
- Attacker input in a render context reaching a template filter makes an advisory entry point naming that filter exploitable at the render call. The analyzer links them only where it is certain of the value: a dict literal with constant keys that the render call alone uses, and a filter the rendered template applies to a context variable, directly or through `{% for %}`, `{% with %}`, constant `{% include %}` and `{% extends %}`, earlier filter arguments and `{% filter %}` blocks, in the blocks that render. A name a tag binds shadows the context, and `{% include ... only %}` passes none of it. The filter's value is its first argument, the filter's own argument its second. A project function rendering what it receives carries it the same way (#146).
- The request the `request` context processor adds reaches a template's filters too, as `{{ request.GET.q|striptags }}`, only where the analyzer is certain of it: every Django template engine the settings assign to `TEMPLATES`, as literals no other code names, lists the processor; the render call passes the request (`render`, `TemplateResponse`, `render_to_string` given `request`) with a certain context, absent or a dict literal; nothing shadows `request`; and the template reads a text attribute of the request object, such as `GET`, `POST`, `COOKIES` or `headers`. Otherwise the filter stays reachable (#146).

## 0.15.0 (2026-09-26)

Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ class BottleModels(ModelPlugin):
| `RouteRegistrar(symbol, argument, label, kinds=ALL, keyword=None)` | A call registering a view at argument `argument` (or `keyword`), such as Django's `path`; the view receives `label` input. |
| `SuffixSink(suffix, kinds, positions=())` | A sink matched by the end of the symbol, for methods of any class such as `objects.raw`. |
| `SafeArgument(symbol, argument, values, position=None, kinds=ALL)` | A call to the sink `symbol` whose `argument` (by keyword, or at `position`) denotes one of `values` is not a sink for `kinds`, such as `yaml.load` with `Loader=yaml.SafeLoader`. Values are written as the analyzer records arguments: a symbol by its canonical name, a constant as Python writes it (`True`). Only a value given explicitly makes the call safe. |
| `TemplateRender(symbol, position=0, keyword="template_name")` | A call rendering, with autoescaping, the template named by its argument at `position` or `keyword`, such as `render_to_string`. What it returns carries no `HTML` when the name is written in the call and the project's template escapes everything it prints (see the usage guide). A name the analyzer cannot read keeps an advisory a template filter reaches under investigation in the VEX document. |
| `TemplateRender(symbol, position=0, keyword="template_name", context=1, request=None)` | A call rendering, with autoescaping, the template named by its argument at `position` or `keyword`, such as `render_to_string`, with the variables of its argument at `context` or `context=` and the request at `request` or `request=` (None: the call takes none); it makes the filter calls the template applies, fed from that context and, with the `request` context processor, that request (see the usage guide). What it returns carries no `HTML` when the name is written in the call and the project's template escapes everything it prints (see the usage guide). A name the analyzer cannot read keeps an advisory a template filter reaches under investigation in the VEX document. |
| `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe for `kinds`, such as `re.fullmatch`. A flow guarded by it is refuted when it carries no other kind into the sink, and is a hotspot otherwise: a redirect validator does not make a shell command safe. `symbol` may be a function of the project, by its project symbol (`python.hc.accounts.views._allow_redirect`), which then counts wherever it is called, in its own module too. |
| `RequestObject(symbol, text=())` | The inputs from `symbol`, an entry point, a route registrar, a typed parameter or a source, are request objects. A view receives the request first (after `self`), then URL parameters, which are text. Reading one of the request's `text` attributes gives text, as Django's `GET` or aiohttp's `query`; its body and files keep the request's kinds. An attribute read counts only when every source of the value is such a request. |
| `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. |
Expand Down
45 changes: 42 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,48 @@ Python call names it, since a class-based view renders its `template_name` insid
Django. Most filters are functions of the same name in `django.template.defaultfilters`;
`escape`, `escapejs`, `linebreaks`, `phone2numeric`, `slice`, `timesince` and `timeuntil`
are `<name>_filter` there, and the filters of `i18n`, `l10n`, `tz` and `humanize` are in
their library's module (`timezone` is `django.templatetags.tz.do_timezone`). The data a
template passes to a filter is not tracked yet, so such a call is reachable, not
exploitable.
their library's module (`timezone` is `django.templatetags.tz.do_timezone`).

Attacker input in the render context makes the filter exploitable at the render call:
`render(request, "app/profile.html", {"bio": request.POST["bio"]})` with
`{{ bio|striptags }}`. The analyzer links a context value to a filter only where it is
certain of it:

- The context is a dict literal with constant keys, used by the render call and
nothing else, written in the call or through a variable. A dict mutated or passed
elsewhere, one unpacking another (`**extra`), or a context built any other way leaves
the filter reachable.
- The filter's value is a context variable, directly or through `{% for %}` and
`{% with %}`, the templates the template includes or extends by a constant name, the
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.
- One file has the template's name; of two files of the same name, which one renders
depends on the loaders.

The filter's value is its first argument and its argument (`cut:bio`) its second, so an
entry point naming only the value in `attacker_arguments` is not exploitable through
the argument. A project function rendering the values it receives carries them the same
way.

With the `django.template.context_processors.request` context processor, a template
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 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
a `request` entry. An entry of that name, a name a tag binds or `{% include ... only %}`
shadows the processor's request.
- The template reads text the user controls: an attribute the request object lists
among its text attributes, such as `GET`, `POST`, `COOKIES` or `headers`; not `user` or
`session`.

Through a project function, only the context entries are followed.

Most vulnerabilities need attacker input in one argument: the path `send_from_directory`
serves, not its `download_name`; the URL `requests.get` fetches, not its body. An entry
Expand Down
6 changes: 3 additions & 3 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,9 @@ class DjangoModels(ModelPlugin):
Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML),
TemplateRender(_sym("django.template.loader.render_to_string")),
TemplateRender(_sym("django.shortcuts.render"), 1),
TemplateRender(_sym("django.template.response.TemplateResponse"), 1, "template"),
TemplateRender(_sym("django.template.loader.render_to_string"), request=2),
TemplateRender(_sym("django.shortcuts.render"), 1, context=2, request=0),
TemplateRender(_sym("django.template.response.TemplateResponse"), 1, "template", 2, 0),
TemplateRender(_sym("django.template.response.SimpleTemplateResponse"), 0, "template"),
Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
# The masked CSRF secret: ASCII letters and digits only, a malformed cookie is
Expand Down
18 changes: 17 additions & 1 deletion src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
SummaryAnalysis,
SummaryIndex,
SymbolRead,
TemplateCalls,
TemplateCallsAnalysis,
build_module_graph,
discover_sources,
project_symbol,
Expand Down Expand Up @@ -126,6 +128,7 @@
escaped_templates,
project_templates,
registered_routes,
request_processor,
unread_renders,
)
from coretrace_python.taint.urls import flow_url
Expand Down Expand Up @@ -160,6 +163,7 @@
RegisteredRoutes,
EscapedTemplates,
ClearingAnalysis,
TemplateCallsAnalysis,
)


Expand Down Expand Up @@ -200,6 +204,7 @@ class ResultsEvicted(TransformationPass):
RegisteredRoutes,
EscapedTemplates,
ClearingAnalysis,
TemplateCallsAnalysis,
}
)

Expand Down Expand Up @@ -435,12 +440,18 @@ def analyze_project(
escaped = escaped_templates(root)
templates = project_templates(root)
clearing = models.clearing(escaped)
template_calls = models.template_calls(
templates.filters, request_processor(modules[name] for name in sorted(analysable))
)
for manager in analysable.values():
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, escaped)
manager.provide(ClearingAnalysis, clearing)
manager.provide(TemplateCallsAnalysis, template_calls)

configuration = _configuration_key(components, plugins, models, advisories, dependencies, routes, escaped)
configuration = _configuration_key(
components, plugins, models, advisories, dependencies, routes, escaped, template_calls
)
keys = module_keys(
graph,
{name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
Expand Down Expand Up @@ -482,6 +493,7 @@ def analyze_project(
advisory_paths,
_encode_routes(routes),
tuple(sorted(escaped)),
template_calls,
),
)
for component in pending
Expand Down Expand Up @@ -694,6 +706,7 @@ class _Batch:
advisory_paths: tuple[Path, ...] = ()
routes: tuple[tuple[str, str, str, int], ...] = ()
escaped: tuple[str, ...] = ()
template_calls: TemplateCalls = field(default_factory=TemplateCalls)


def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
Expand Down Expand Up @@ -721,6 +734,7 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, escaped)
manager.provide(ClearingAnalysis, models.clearing(escaped))
manager.provide(TemplateCallsAnalysis, batch.template_calls)
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
return {name: encode(entry) for name, entry in results.items()}
Expand Down Expand Up @@ -749,6 +763,7 @@ def _configuration_key(
dependencies: DependencyGraph,
routes: Routes | None = None,
escaped: frozenset[str] = frozenset(),
template_calls: TemplateCalls | None = None,
) -> str:
"""Everything a module's results depend on besides the project sources (§11)."""

Expand All @@ -766,6 +781,7 @@ def _configuration_key(
repr(dependencies.errors),
repr(_encode_routes(routes or {})),
repr(sorted(escaped)),
repr(template_calls),
)


Expand Down
8 changes: 8 additions & 0 deletions src/coretrace_python/interprocedural/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
project_symbol,
)
from coretrace_python.interprocedural.summaries import (
FILTER_ARGUMENTS,
Cleared,
Clearing,
ClearingAnalysis,
Expand All @@ -31,10 +32,14 @@
SummaryAnalysis,
SummaryIndex,
SummaryTable,
TemplateCalls,
TemplateCallsAnalysis,
TemplateFilter,
cleared_by,
)

__all__ = [
"FILTER_ARGUMENTS",
"Arguments",
"CallGraph",
"CallGraphAnalysis",
Expand All @@ -56,6 +61,9 @@
"SummaryTable",
"SymbolRead",
"Target",
"TemplateCalls",
"TemplateCallsAnalysis",
"TemplateFilter",
"UnknownTarget",
"build_module_graph",
"cleared_by",
Expand Down
Loading
Loading