diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0f1d4..6a64064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Precision - The SSRF sinks of Requests and httpx read the destination only: the URL (the first argument, the second after the method for `request` and `stream`, or `url=`) or the prepared request `send` takes. Attacker data in the body, the JSON, the headers or the query parameters is no longer a server-side request forgery. `Sink` gains `keywords` next to `positions`, and function summaries keep the name of each keyword argument, so a project function forwarding `url=` still reaches the sink; the cache format is bumped (#128). +- `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) diff --git a/docs/plugins.md b/docs/plugins.md index e69f279..064dba3 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 2a1e3a9..bff8f4f 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`), by position or as `url=`; the body, headers and query parameters are not the destination. | -| `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 `", + "", + "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"] == []