Repository navigation
fix(templates): read what a template leaves open, and comments, as a browser does - #275
Merged
Merged
Conversation
Pin what the changes for #272 must keep: a script closed in its own template, a comment an include closes before a script, a variable in a comment that holds no template tag, comments that close early, and templates that end inside a comment or are extended.
… the rest Copying the rest of the template at each "<" made the inspection quadratic in the number of tags, even for well-formed markup. Each pattern is now matched at its position; verdicts are unchanged.
…the template An included fragment that opens a <script> and leaves its end tag to the including template was read as HTML, and a variable inside it was taken as escaped. A script or style block left open now runs to the end of the template, read once: a variable inside is refused, even where the page ends there and the browser would never run it.
An end tag that never closes was rescanned to the end of the template at every "<", and a variable after it was read as text. It now runs to the end, read once, and a variable inside is refused as in a closed end tag.
A template tag inside a comment can close it, and a condition around its opener can leave it out, so markup the source shows as commented out may be live. A comment now ends at its close or at the first template tag inside it, and what follows the tag is read as markup.
A browser ends a comment at "--!>" as well as at "-->", and reads "<!-->" and "<!--->" as complete comments. Reading on to a later "-->" took the markup in between for part of the comment.
A browser shows nothing after a comment that never closes, whether the template is rendered alone or included. The rest of the text is no longer read as markup, and a close that does not exist is searched for once, which keeps the inspection linear.
The inspection of a template returns a named tuple instead of a pair, with one refused verdict for every early exit. Behaviour is unchanged.
A template that ends inside a script or style block, a tag or an end tag hands that construct to the markup following its include. The markup reading now returns this final context with its verdict, in the same pass, and a template including such a template is not established as escaping. Extends is unaffected: a child's blocks stand before the parent's end.
The tag name test accepted a word boundary, so <style-switcher> or <script-editor> opened a style or script block, which now runs to the end of the template when left open: their content was refused as CSS or JavaScript. A tag name runs to whitespace, "/" or ">", and only "script" and "style" themselves hold raw text.
A comment ending at a template tag is followed by more comments, each of which searched again for the same close further on: quadratic in the number of comments before one close. The close found is kept for every comment that starts before it.
The verdict of a template lists the templates it extends apart from those it includes, for the rule that follows. Behaviour is unchanged.
A template that extends another renders it, its blocks filled in, and so ends where the extended template ends. One extending a template that ends inside a script, a style, a tag or an end tag hands that construct on to the markup after its include, like a template ending there itself.
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 #272.
The escaping inspection of Django templates now reads each template's markup in one pass that also tracks what the template leaves open at its end and how comments end. Constructs left open, comments and that final context all come from this one pass, so no separate rule or rescan can disagree with it.
The rules:
<script>or<style>block (those names exactly, not<style-switcher>) or an end tag that the template leaves open runs to the end of the template, read once. A variable inside is refused, as in a closed one. This also covers C: a variable in a script left open is refused even where the document ends there and the browser would never run it, an accepted prudent false positive.-->,--!>, or at once for<!-->and<!--->), or at the first template tag inside it, whichever comes first, and what follows that tag is read as markup. An include there may close the comment, and a condition around its<!--may leave it out. A variable cannot close it, since it is escaped. A comment left open, with no tag after it, hides the rest of the text.extendstoo. The extending template's own verdict is unaffected, since its blocks stand where the parent places them, before the parent's end.test(templates): cover markup the inspection already reads rightperf(templates): match markup where it starts instead of on a copy of the restfix(templates): read a script or style block left open to the end of the templatefix(templates): read an end tag left open to the end of the templatefix(templates): end a comment at a template tag inside itfix(templates): close a comment where a browser closes it--!>,<!-->,<!--->fix(templates): let a comment left open hide the rest of the templaterefactor(templates): name what the inspection of a template establishesfix(templates): refuse including a template that ends inside a constructdocs: state how templates left open and comments are readdocs/usage.mdfix(templates): read only script and style elements as raw textperf(templates): find a comment close once for the comments before itrefactor(templates): record the templates a template extendsfix(templates): end a template extending another where that one endsdocs: state that a template extending another ends where that one endsdocs/usage.mdReview
Three findings on the first ten commits, each reproduced and then fixed in its own commit, with tests that failed before the fix:
An extended template's open end. The three templates were:
base.html:<p>{% block body %}{% endblock %}</p><script>child.html:{% extends "base.html" %}{% block body %}hello{% endblock %}page.html:{% include "child.html" %}var name = {{ name }};</script>Django renders
<p>hello</p><script>var name = …;</script>, and Chrome runs it, yet nothing was reported. The open end ofbase.htmlis now handed on to the templates extending it, along a chain of extends too.page.htmlis refused, and the view reportsxss. Unlike #273, the block stands before the script, not inside it.Comments before one close.
"<!--{% include 'empty.html' %}" * n + "-->"searched the final close again from each comment, which is quadratic. The close found is now kept for every comment that opens before it.mainCustom elements.
<style-switcher><p>{{ name }}</p></style-switcher>(or<script-editor>) opened a style or script block, since the tag-name test accepted a word boundary. With nothing closing it, the block ran to the end of the template, and the variable was refused. A tag name now runs to whitespace,/or>, as the tokenizer reads it, so onlyscriptandstylethemselves hold raw text.mainaccepted this template, and so does this branch now.Not in this change
#273 stays open. A
{% block %}(F) or an{% include %}placed inside another construct inserts markup there that no rule checks.<script>. Stillno findings, and the script runs.<a title="{% include "q.html" %}{{ name }}">x</a>, withq.htmlholding" onclick=". Rendered with Django, it becomes<a title="" onclick="alert(1)">x</a>, yet--checkstill reportsno findings.Results
The reproductions of #272
Each is a Django project with one view rendering
page.htmlwithrequest.GET["name"]:main<script>closed by its includerxssxssxssxss, the prudent false positivexssxss<script>xssxss<p>hello</p><!-- {{ name }} --><script>(#273)Verdicts
The verdicts before and after the change were compared on 1,618,480 texts: every text of up to five tokens of a small alphabet, plus 500,000 random texts of a wider one. The tokens are comments and their closes,
<script>,<style>, tags, end tags, quotes, variables and template tags. Every difference has one of these causes, and none is left unexplained.\x00a variable,\x01a template tag)<script>\x00</a\x00<!-->or<!--->, or by--!>, where the old reading ran to a later--><!--><a\x00--><!--\x01<a\x00--><!--<a\x00--!>, which the old reading did not close<!--<a\x00--!>The lost refusals are markup a browser shows nothing of.
Corpus
escaped_templatesis identical before and after on the 10 projects of the local corpus that have templates. They include healthchecks (136 of 285 templates escaped), pygoat (101 of 128), Vulnerable_fastapi, uploadbox, DjangoGoat, dvpwa, luigi and flask 3.1.0. The remote battery could not be reached, so superset, sentry, saleor and mitmproxy were not compared.Cost
templates._inspectwas timed in one subprocess per size, with a 60-second limit, on the same machine and under the same load, before (ff49a2c) and after:"<" * n"</a" * (n // 3)"<!--" * (n // 4)"<script>" * (n // 8)"<style>" * (n // 7)"<!--{% if x %}" * (n // 14)"<p>x</p>" * (n // 8)--checkon a project whose only file is a 300,000-character template of each shape takes 0.2 to 0.3 s. Onmain, five of these shapes were measured earlier, under heavier load (#272): 0.5 s for the well-formed control and 1.6 to 66.7 s for the others. These timings come from Python 3.12.15 on macOS with an Apple M3.Tests
The new file
tests/test_template_open_constructs.pyhas 37 tests, added with the commit whose behaviour each one checks. Each inspection and analysis runs in a subprocess with a timeout. No existing test changes, and no regression snapshot changes.mainxss<!-- {{ name }} -->stays escaped and reports nothing<!-->,<!--->and--!><!-->or--!>, before a later-->, is refused<style>, a<script>or an end tag left open is refusedEach commit leaves the suite green, and the tests a commit adds fail on the commit before it, those tests and no other. The refactor adds none.
Each of these mutations of the change makes at least one test fail:
-->as the only close;extendstreated as an include;<!--, which brings back the quadratic cost;Verification
Python 3.12.15 (macOS), this branch on
mainatff49a2c:mypyandruff check .: clean;b1c4ed3: 978 passed.