From 8dc9f5b5d6c4557f46ff79e6ace43bc08a5fe287 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Sep 2026 23:10:58 +0200 Subject: [PATCH 1/2] test: specify when Django templates escape what render_to_string returns --- tests/test_template_escaping.py | 178 ++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/test_template_escaping.py diff --git a/tests/test_template_escaping.py b/tests/test_template_escaping.py new file mode 100644 index 0000000..7cea9da --- /dev/null +++ b/tests/test_template_escaping.py @@ -0,0 +1,178 @@ +"""Django templates escape what they render, where the engine can establish it. + +``render_to_string(name, context)`` returns HTML in which autoescaping has escaped every +variable, so attacker data reaching ``HttpResponse`` through it is no cross-site +scripting, provided the template really escapes. The engine establishes that only when +all of these hold: + +- it finds every template of that name under a ``templates`` directory, and everything + those templates extend or include; +- none of them marks output safe (``|safe``, ``|safeseq``, ``{% autoescape off %}``); +- none uses a tag beyond Django's own, or loads a tag library beyond its built-ins; +- none places a variable where HTML escaping does not protect it: a ``", + "", + "x", + "x", + "
x
", + "
x
", + "
x
", + "
x
", + "{% load custom_tags %}

{{ name }}

", + "{% custom_tag name %}", + "{% include template_name %}", + "{% extends base_name %}", + "{% include 'app/missing.html' %}", + ], +) +def test_a_template_that_may_output_unescaped_data_is_not_escaped(tmp_path: Path, template: str) -> None: + assert escaped_templates(write(tmp_path, {"app/templates/app/page.html": template})) == frozenset() + + +def test_a_template_escapes_no_better_than_what_it_extends_and_includes(tmp_path: Path) -> None: + files = { + "app/templates/base.html": ( + "{% load static %}{% block content %}{% endblock %}" + "" + ), + "app/templates/app/page.html": ( + "{% extends 'base.html' %}{% block content %}

{{ name }}

{% include 'app/part.html' %}{% endblock %}" + ), + "app/templates/app/part.html": "{{ name }}", + "app/templates/raw.html": "{% block content %}{% endblock %}

{{ name|safe }}

", + "app/templates/app/on_raw.html": "{% extends 'raw.html' %}{% block content %}{{ name }}{% endblock %}", + } + + assert escaped_templates(write(tmp_path, files)) == {"base.html", "app/page.html", "app/part.html"} + + +def test_every_template_of_a_name_must_escape(tmp_path: Path) -> None: + files = {"one/templates/page.html": "

{{ name }}

", "two/templates/page.html": "

{{ name|safe }}

"} + + assert escaped_templates(write(tmp_path, files)) == frozenset() + + +def test_settings_disabling_autoescape_leave_no_template_escaped(tmp_path: Path) -> None: + files = { + "app/templates/app/page.html": "

{{ name }}

", + "site/settings.py": "TEMPLATES = [{'BACKEND': 'x', 'OPTIONS': {'autoescape': False}}]\n", + } + + assert escaped_templates(write(tmp_path, files)) == frozenset() + + +# --------------------------------------------------------------------------- analysis + + +def test_rendering_an_escaped_template_is_not_cross_site_scripting(tmp_path: Path) -> None: + assert xss(tmp_path, "

Hello {{ name }}

") == [] + + +def test_rendering_a_template_that_marks_the_value_safe_stays_cross_site_scripting(tmp_path: Path) -> None: + assert xss(tmp_path, "

Hello {{ name|safe }}

") == [5] + + +def test_a_template_named_by_an_expression_stays_cross_site_scripting(tmp_path: Path) -> None: + assert xss(tmp_path, "

Hello {{ name }}

", name="'app/' + 'page.html'") == [5] + + +def test_a_template_outside_a_templates_directory_is_not_assumed_escaped(tmp_path: Path) -> None: + # Django finds it through a DIRS entry the engine does not read: a known limitation. + view = VIEW.format(name="'page.html'") + write(tmp_path, {"app/views.py": view, "frontend/page.html": "

Hello {{ name }}

"}) + + findings = engine.analyze_project(tmp_path, [engine.BUNDLED_PLUGINS]).findings + + assert [f.span.start_line for f in findings if f.rule_id == "xss"] == [5] + + +def test_a_single_file_check_assumes_no_template_escaped() -> None: + findings = engine.check( + SourceManager().add_source("views.py", VIEW.format(name="'app/page.html'")), [engine.BUNDLED_PLUGINS] + ) + + assert [f.rule_id for f in findings] == ["xss"] + + +def test_a_template_gaining_safe_invalidates_the_cached_results(tmp_path: Path) -> None: + cache = ProjectCache(tmp_path / "cache") + root = write(tmp_path / "src", {"app/views.py": VIEW.format(name="'app/page.html'")}) + page = write(root, {"app/templates/app/page.html": "

{{ name }}

"}) / "app/templates/app/page.html" + + first = engine.analyze_project(root, [engine.BUNDLED_PLUGINS], cache=cache) + page.write_text("

{{ name|safe }}

", encoding="utf-8") + second = engine.analyze_project(root, [engine.BUNDLED_PLUGINS], cache=cache) + + assert [f.rule_id for f in first.findings if f.rule_id == "xss"] == [] + assert second.reused == () + assert [f.span.start_line for f in second.findings if f.rule_id == "xss"] == [5] + + +def test_modules_analysed_in_other_processes_see_the_escaped_templates(tmp_path: Path) -> None: + root = write(tmp_path, {"app/views.py": VIEW.format(name="'app/page.html'"), "app/other.py": "X = 1\n"}) + write(root, {"app/templates/app/page.html": "

{{ name }}

"}) + + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS], jobs=2).findings + + assert [f.rule_id for f in findings if f.rule_id == "xss"] == [] From 4d054a62847db750f90e440cc53fd76f905506eb Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Sep 2026 23:10:59 +0200 Subject: [PATCH 2/2] fix: clear render_to_string output only for templates shown to escape --- CHANGELOG.md | 6 + docs/plugins.md | 1 + docs/usage.md | 25 +- .../bundled/models/django/django_models.py | 2 + src/coretrace_python/engine.py | 21 +- src/coretrace_python/taint/__init__.py | 5 + src/coretrace_python/taint/engine.py | 19 +- src/coretrace_python/taint/models.py | 20 ++ src/coretrace_python/taint/templates.py | 250 ++++++++++++++++++ tests/regression/expected/pygoat.json | 18 -- 10 files changed, 345 insertions(+), 22 deletions(-) create mode 100644 src/coretrace_python/taint/templates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e1a63..c921065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Precision + +- `render_to_string` escapes what it renders where the analyzer can establish it: on a directory check, it reads the project's Django templates, and a template escapes when neither it nor what it extends or includes marks output safe, uses a tag, filter or library beyond Django's own, or prints a variable where HTML escaping does not protect it, and no settings turn autoescaping off. Data reaching a response through such a template is no longer `xss`; any other template keeps the flow reported. `TemplateRender` declares such rendering calls, and the escaped templates are part of the cache key (#130). + ## 0.6.0 (2026-09-24) ### Reports diff --git a/docs/plugins.md b/docs/plugins.md index e766074..1df0cc6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -149,6 +149,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). | | `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe, such as `re.fullmatch`; a flow guarded by it is refuted. | | `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. | diff --git a/docs/usage.md b/docs/usage.md index c749c6c..6f35bd7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -109,7 +109,7 @@ a sanitizer for that kind of sink. | `sql-injection` | A database statement (`cursor.execute`, SQLAlchemy `text`, Django `raw`, …). Parameters of a parameterised query are not statements. | | `path-traversal` | A file system path (`open`, `send_file`, `os.remove`, …). | | `ssrf` | The URL of an HTTP client request (Requests, httpx, `urllib`). | -| `xss` | An HTTP response body built without escaping. | +| `xss` | An HTTP response body built without escaping. A Django template rendered by `render_to_string` escapes, where the analyzer can establish it (below). | | `insecure-deserialization` | `pickle`, `marshal`, `dill`, `jsonpickle`, `shelve`, and the YAML loaders (`yaml.load`, `load_all`, `full_load`, `unsafe_load` and their several-document versions); `yaml.load` and `load_all` given a safe loader (`SafeLoader`, `BaseLoader`) are not. | | `open-redirect` | An HTTP redirect target. | | `plaintext-credential-storage` | A parameter named like a password stored in a database without hashing. Medium confidence, since a name is a hint. | @@ -122,6 +122,29 @@ numeric value proven by `int()`, `len()` or bounded arithmetic, a validator such or an authorization decorator such as `login_required`, makes it a hotspot reported at medium confidence. The verdict and its evidence are in the finding's metadata. +Django's `render_to_string(name, context)` returns HTML in which autoescaping escaped +every variable, so data reaching a response through it is no `xss`, if the template really +escapes. On a directory check, the analyzer reads the project's templates to decide it. + +It establishes escaping only when all of the following hold: + +- It finds every template of that name under a `templates` directory, by the path Django + uses (`app/page.html`), and everything those templates extend or include. +- None of them marks output safe: `|safe`, `|safeseq` or `{% autoescape off %}`. +- None of them uses a tag or filter beyond Django's own, or loads a library beyond its + built-ins (`static`, `i18n`, `l10n`, `tz`, `cache`, `humanize`). +- None of them places a variable where HTML escaping does not protect it: a `