Repository navigation
fix(templates): read start tags as the HTML tokenizer does - #269
Merged
Merged
Conversation
_START_TAG could split a run of attribute-name characters or of an unquoted value between attributes in exponentially many ways, and tried them all when no > followed, so a few dozen characters under a templates directory, such as superset's thumbnail.png, stopped the analysis. Start tags and attributes are now read as the HTML tokenizer reads them, each token told apart by its first character, so matching is linear. A tag a template leaves open at its end is read to the end and its attributes checked, as it runs into the markup that follows once the template is included. Closes #263.
A quoted value that did not close was read again as an unquoted value: in <a onclick="/* title='<{{ name }}'> the value ended at the first space and title='...' read as a separate, safe attribute, while the quote closes in the including template and the variable belongs to the handler. A value that opens a quote now runs to the matching quote or, left open, to the end of the template, as the HTML tokenizer reads it.
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 #263.
_START_TAGcould split a run of attribute-name characters, or of an unquoted value, between attributes in exponentially many ways, and tried them all when no>followed. A few dozen characters under atemplatesdirectory stopped the analysis: superset'sthumbnail.png, or<afollowed by 40 letters.Start tags and their attributes are now read as the HTML tokenizer reads them:
/or>;/are skipped;=, runs to whitespace,/,>or=;=. A value that opens a quote runs to the matching quote; any other value runs to whitespace or>.Each token is told apart by its first character, so matching is linear.
A template is often a fragment: once included, what it leaves open runs into the markup that follows. So:
docs/usage.mdReview: a quoted value left open
In the first commit, a quoted value that did not close was read again as an unquoted value:
fragment.html:<a onclick="/* title='<{{ name }}'>page.html:{% include 'fragment.html' %}">click</a>Here
"/*ended at the first space, andtitle='…'read as a separate, safe attribute. In fact the double quote opened afteronclick=only closes inpage.html, so the variable belongs to the handler: withnameset to*/alert(1);//, it runs.mainxssfor a view passingrequest.GET['name']Why not only possessive quantifiers
Making the runs possessive removes the backtracking, but the former pattern's language depended on it. Splitting the end of a run into an attribute name is what let it read
<div =x onclick="{{ v }}">and refuse the handler. On the verdict bench below, the possessive pattern lost 3,146 refusals of this kind, so it was not kept.Verdicts against the former pattern
Verdict bench
_escapes_where_printedwas compared under the former and the new patterns on 1,097,870 texts with the output marker:<a;Lost refusals: 76. In each of them, a browser keeps the output in a quoted value of a harmless attribute:
<div = title="{{ v }}">: an attribute named=, then a quotedtitle.<a b="<{{ v }}left open: the value stays quoted to the end and beyond.<a= '=' onclick="{{ v }}">,<aa ="=" style={{ v }}>and the like: the quote opened by the earlier value never closes.onclick,styleorsrcdoclies inside that value and is no attribute at all; the former pattern read it as one.Added refusals: 222,836. These are outputs a browser would place in a tag or attribute name, such as
<a/{{ v }}>or<a{{ v }}left open. The former read them as text.Corpus
escaped_templatesis identical under both patterns on the 38 projects of the battery, superset compared without its PNG. They include healthchecks (136 templates escaped), pygoat (101), sentry (44), home-assistant (25), saleor (6), superset without its PNG (21) and mitmproxy (7).With its PNG, superset did not finish under the former pattern within 120 s. Under the new one it finishes in 0.5 s, with 22 templates escaped: the 21 above plus the PNG, which prints nothing.
Tests
New file
tests/test_template_tag_backtracking.py, 22 tests. Each runs in a subprocess with a timeout, so an inspection that does not end fails instead of hanging the suite.templates, are inspected to the endmain: timeouttitle, it is still escapedmain: passed (preservation)titleis escapedmain: the first three failed/, or in a name beginning with=, is refused;=then a quotedtitleis escapedmain: failedtitleis escaped; the two templates of the review give no escaping and anxssfindingNo existing test is changed.
Mutations caught:
>required again;=;/no longer skipped;Verification
ruff check .andmypy: clean.test_packaging, which also fails onmainin this environment).full-stack-fastapi-template, a PEP 758 syntax only 3.14 accepts, as onmain).mainatea73758with both commits: unit suite 2,368 passed and 1 skipped; regression 19 of 19; Enterprise suite 978 passed.Superset with its PNG
The battery used to remove
thumbnail.pngfrom superset as a workaround for this issue. With this change, superset is analysed with it.Configuration: superset 4.1.1, without its test files,
-j 16, Enterprise plugins atb1c4ed3, Linux, Python 3.12, 32 cores, otherwise idle.main(ea73758), with the PNGmain, without the PNG, seed 0Not in this change
Other constructs of the inspection remain quadratic on hostile input. Each one still ends:
<not followed by a letter, repeated</a)<!--)<script>blocksThey come from the rest of the template being copied at every
<, and from the comment, raw-text and end-tag patterns scanning to the end of the text when they do not close. Measured locally on macOS.