Repository navigation
fix(templates): read each page as Django puts its blocks and includes together - #280
Merged
Merged
Conversation
…ludes
_inspect read a template into one flat markup in which every tag was a marker.
Parsing now keeps apart its blocks, with what each holds, its includes and
{{ block.super }}, and the flat markup is rebuilt from them; each file is parsed
once. The verdicts are unchanged: equal to the former ones on 2,238 template
files of Django projects and on 218,421 generated texts.
…ncludes in place Each template was read on its own: a child's block placed inside its parent's <script>, or an include placed inside an attribute value, was read as HTML data in its own file, and a variable there was taken for escaped (#273). Each page is now also put together as Django renders it, and read once more: the templates it extends, each block taking the definition of the most derived one and {{ block.super }} the next one up, and the page of each template it includes, in place. A template escapes when both readings say so. An include that cannot be put in place, of a name several files have or on the way to itself, and a parent several files give leave the page not established for now. A page is bounded at 8 MiB, checked as it grows.
…s have Django renders the first file its loaders find, which the engine does not know. Such an include now inserts a marker standing for each file. The marker is refused in any attribute, tag, end tag, script or style block and comment, and right after "<", "</", "<!" or "<!-", where a file could end the construct or complete one. It is accepted in HTML data when each file escapes and ends in data, with nothing of it left open. A property test checks that any accepted text ending in data, put in place of the marker, keeps the page accepted.
…y be A template extending a name several files have is now put together with each of them, at each level of its chain, and must escape with each. An include of a template that renders several pages inserts a marker standing for each. One template renders at most 64 pages, and 16 MiB together.
A template including itself, directly or through others, was not put together. Its recursive include now inserts a marker standing for its own page: accepted only in HTML data, and only when that page ends in data. Property tests prove it on generated templates: a text holding such a marker, once accepted, stays accepted unfolded level after level, and a template established stays established when its recursion is written out as templates including each other.
…plates not read A page beyond 8 MiB once put together, and a template with more than 64 pages or 16 MiB in all, are listed in Coverage.unread_templates with the reason. template_escaping returns them with the names of the templates established to escape; escaped_templates keeps its interface.
The usage guide, the module's docstring and the changelog state how pages are put together, how a name several files have and a template including itself are read, and the bounds on a page.
…filter or a tag
Only a bare {{ block.super }} was put in place: through a filter, it read as an
escaped variable. Django still prints the parent's markup there, marked safe,
and escape or default keep it as it is. With the parent's block standing between
two quotes of a tag, {{ block.super|escape }}{{ name }} rendered the variable in
an event handler. A use of block.super other than a bare {{ block.super }}, in a
variable or a tag, now leaves the template not established. Every use in the
corpus is bare.
The definitions a page renders were walked again at every {{ block.super }},
before any bound applied. A chain whose every block renders its parent twice
doubled the work at each level, though the page stayed empty: 20 levels took
16 s. Each definition is now put together once, then reused, and the includes
are collected visiting each definition once: 60 levels take 0.1 s. The 8 MiB
bound applies to each definition as it grows.
…ate extends its own name Django's loaders skip the files an extends chain already holds: a page.html extending 'page.html' extends another file of that name. Such a chain was taken for a cycle, and the page reported as xss, though Django keeps the data escaped. The files already in the chain are now left out, and each remaining one is checked; with none left, the page is not established, as Django raises an error.
…ut in a bare block.super
The check looked for block.super itself, and an alias of the block slipped past
it. With the parent's block standing between two quotes of a tag,
{% with parent=block %}{{ parent.super }}{{ name }}{% endwith %} rendered the
variable in an event handler, through |escape too. A template naming the
variable block anywhere but in a bare {{ block.super }}, in a variable or a tag,
outside string literals and the names of block tags, is now not established. A
bare {{ block.super }} in an included template, which Django renders as nothing
there, keeps its reading.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #273.
Each Django template was read on its own, so the inspection could not see what a
{% block %}or an{% include %}puts where it stands:{{ name }}inside its parent's<script>was read as HTML data in the child." onclick=").In both, Django renders a variable that runs as script, and no
xsswas reported.Each page is now also put together as Django renders it, then read once more by the reader of #272:
{{ block.super }}the next one up;A template escapes when the inspection of each file on its own establishes it, as before, and its pages, put together, read as escaping. The composition adds a check; it removes none.
Rule
A name several files have. Django renders the first file its loaders find, which the engine does not know.
<script>or<style>block and a comment, and right after<,</,<!or<!-, where a file could end the construct or complete one. In HTML data, it is accepted when each file escapes and ends in data, with no construct, no comment and no<,</,<!or<!-left open at its end.q.htmlexists in two template directories, one of them holding" onclick=", a variable is allowed in the quotedtitle. Yet Django 5.1.15 renders<a title="" onclick="alert(1)">x</a>when that directory comes first.A template rendering several pages, because a name in its chain has several files, inserts the same marker where another includes it.
An include on the way to itself, directly or through others, inserts a marker standing for the template's own page, under the same conditions. Property tests prove it on generated templates (below).
Bounds, checked while a page is put together:
Beyond a bound, the template is not established, and it is listed in
Coverage.unread_templateswith the reason.refactor(templates): parse a template into its markup, blocks and includesfix(templates): read the page each template renders, its blocks and includes in placefix(templates): accept in HTML data an include of a name several files havefix(templates): check a page with each file the name of its parent may befix(templates): insert a marker for an include on the way to itselffeat(templates): list the pages that cannot be put together among templates not readtemplate_escaping,Coverage.unread_templatesdocs: describe how a page is put together to read Django templatesdocs/usage.md, docstring,CHANGELOG.mdfix(templates): keep the alert when block.super is printed through a filter or a tag{{ block.super|escape }}fix(templates): put each block definition of a page together once{{ block.super }}fix(templates): leave out the files already in the chain when a template extends its own namedocs: state how block.super and a template extending its own name are readdocs/usage.md,CHANGELOG.mdfix(templates): keep the alert wherever the variable block is named but in a bare block.superdocs: state that the variable block is read only in a bare block.superdocs/usage.md,CHANGELOG.mdReview
Three points on the first seven commits.
{{ block.super }}through a filter (P1). Only a bare{{ block.super }}was put in place; with a filter, it read as an escaped variable. Django still prints the parent's markup there, marked safe, andescapeordefaultkeep it as it is. With<a title="{% block attr %}" onclick="{% endblock %}">x</a>as the parent,{{ block.super|escape }}{{ name }}in the child renders<a title="" onclick="alert(1)">x</a>, and nothing was reported. The same holds for{% with parent=block.super %}{{ parent }}{% endwith %}. A use ofblock.superother than a bare{{ block.super }}, in a variable or a tag, now leaves the template not established. All 532 uses in both corpora are bare.Work doubling before any bound (P2). The definitions a page renders were walked again at every
{{ block.super }}, before the bounds applied. In a chain whose every block renders its parent twice, the work doubled at each level, though the page stayed empty: 20 levels took 16 s, against 0.3 s onmain. Each definition is now put together once, then reused, and the includes are collected visiting each definition once. 60 levels take 0.1 s, and the 8 MiB bound applies to each definition as it grows.A template extending its own name (P2). Django's loaders skip the files an extends chain already holds, so a
page.htmlextending'page.html'extends another file of that name. Such a chain was taken for a cycle, and the page reported asxss, though Django keeps the data escaped. The files already in the chain are now left out, and each remaining one is checked. With none left, the page is not established, as Django raises an error.An alias of the block (second round). The check looked for
block.superitself, and an alias of the block slipped past it:{% with parent=block %}{{ parent.super }}{{ name }}{% endwith %}, through|escapetoo. A template naming the variableblockanywhere but in a bare{{ block.super }}is now not established, whatever the use: an alias, an attribute, a filter, a tag. This includes an{% include … with b=block %}, a prudent false positive: Django renders an include apart from the blocks around it, andblock.supergives""there. For the same reason, a bare{{ block.super }}in an included template renders nothing, as in Django. Across both corpora, the variableblockis named elsewhere only by wagtail templates, which already use tags of their own and are not established, and the name is never an alias of the block there.Results
Corpora.
The check for a Python file turning autoescaping off is ignored here, so that every project's templates count.
mainWhat is lost. Every loss is a template that includes
django/forms/widgets/attrs.htmlinside an<input>tag: Django's form widgets, and wagtail'ssingle_checkbox.html.attrs.htmlprints{{ name }}as an attribute name, which the inspection already refuses when a single template writes it. The time ofescaped_templatesdoes not grow on any project.Reproductions.
<script>{% block js %}{% endblock %}</script>, rendered alone;jsblock holds no variable, and a sibling that prints{{ name }}in its body;{{ name }}in a block placed insideclass="…";{{ block.super }}in a script block;title;Tests
The new file
tests/test_template_page_composition.pyholds 25 tests, 51 cases. No test existing onmainchanges.Before each fix:
block.superthrough a filter or a tag fail; the doubling chain does not end within 60 s; the template extending its own name, rendered escaped by Django, is reported. The bound on a chain doubling its content, and the two guards beside the template extending its own name, pass already. On the eleventh commit, the two aliases of the block fail, and the bare{{ block.super }}of an included template passes.Proof by tests for the marker.
escaped_templatesestablishes 1,260 of them. For each, the recursion written out as templates including each other, four levels down, is established too.Mutations. Each of these makes at least one test fail:
<,</,<!or<!-;block.superallowed through a filter, or in a tag;blockallowed in a variable, or in a tag;{{ block.super }}refused too.Compatibility
xssmay be reported where it was not, on the pages listed under Results and on any page of these shapes.template_escaping(root)returns the names of the templates established to escape and the entries of the pages that could not be put together.escaped_templates(root)is unchanged.Coverage.unread_templatesmay list such pages. A VEX statement that relies on every template having been read then stays under investigation.Not in this change
-->, and the markup after that-->is read from the wrong state. This is a regression of templates: constructs left open, and template tags inside comments, hide cross-site scripting; unclosed ones make the inspection quadratic #272, reported on 0.23.0, and not released.project_templatesfollows each path through nested includes. Two includes of one template at each of 18 levels take 0.8 s, and the time quadruples every two levels.Verification
Python 3.12, on
mainatabd028c:ruff check .andmypy: clean on each commit;