Skip to content

fix(templates): read start tags as the HTML tokenizer does - #269

Merged
SizzleUnrlsd merged 2 commits into
mainfrom
fix/template-start-tags
Oct 21, 2026
Merged

SizzleUnrlsd merged 2 commits into
mainfrom
fix/template-start-tags

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #263.

_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. A few dozen characters under a templates directory stopped the analysis: superset's thumbnail.png, or <a followed by 40 letters.

Start tags and their attributes are now read as the HTML tokenizer reads them:

  • the tag name runs to whitespace, / or >;
  • between attributes, whitespace and a stray / are skipped;
  • an attribute name, which may begin with =, runs to whitespace, /, > or =;
  • a value follows =. 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:

  • a tag left open at the end of a template is read to the end, and its attributes are checked;
  • a quoted value left open stays quoted to the end of the template, and is never read again as unquoted values and attributes.
Commit Change
fix(templates): read start tags as the HTML tokenizer does the reading above, tests, docs/usage.md
fix(templates): keep a value left open quoted to the end of the template review: a quoted value left open was read again as an unquoted value, below

Review: 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, and title='…' read as a separate, safe attribute. In fact the double quote opened after onclick= only closes in page.html, so the variable belongs to the handler: with name set to */alert(1);//, it runs.

main First commit With the review fix
templates established as escaping none both none
xss for a view passing request.GET['name'] reported not reported reported

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_printed was compared under the former and the new patterns on 1,097,870 texts with the output marker:

  • every text of up to 6 characters, over 9 characters, after <a;
  • 400,000 random texts;
  • 100,000 random tags with dangerous attributes.

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 quoted title.
  • <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, style or srcdoc lies 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_templates is 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.

Tests What they check Before
Unclosed tags (5) four shapes that never close, and a binary file under templates, are inspected to the end main: timeout
Long runs (6) a variable after 5,000 characters in an event handler, an unquoted value, a URL attribute, a handler right after a quoted value, or an attribute name is still refused; in a quoted title, it is still escaped main: passed (preservation)
Tag left open at the end (4) its event handler, unquoted value and attribute name are refused; its quoted title is escaped main: the first three failed
Browser-like reading (3) a variable after a stray /, or in a name beginning with =, is refused; = then a quoted title is escaped main: failed
Quoted value left open (4) handlers opened with a double or a single quote are refused; an open quoted title is escaped; the two templates of the review give no escaping and an xss finding first commit: failed

No existing test is changed.

Mutations caught:

  • > required again;
  • no attribute name beginning with =;
  • a stray / no longer skipped;
  • a quoted value that cannot run to the end of the template.

Verification

  • Local runs (macOS, Python 3.14), with the CI's steps:
    • ruff check . and mypy: clean.
    • Unit suite: 2,368 passed, 1 failed (test_packaging, which also fails on main in this environment).
    • Regression: 18 of 19 (full-stack-fastapi-template, a PEP 758 syntax only 3.14 accepts, as on main).
    • These runs keep these two environment differences and are not a green CI.
  • Linux, Python 3.12, main at ea73758 with both commits: unit suite 2,368 passed and 1 skipped; regression 19 of 19; Enterprise suite 978 passed.
  • Cost on hostile input: inspection of start tags, unclosed or with open quoted values, takes at most 0.11 s for 1,000,000 characters (macOS).

Superset with its PNG

The battery used to remove thumbnail.png from 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 at b1c4ed3, Linux, Python 3.12, 32 cores, otherwise idle.

Run Time Peak memory Findings
main (ea73758), with the PNG stopped at 20 min (earlier run)
both commits, with the PNG, seed 0 391 s 417 MB 187
first commit, with the PNG, seeds 0 and 1 387 s, 389 s 417 MB, 418 MB 187, the same
main, without the PNG, seed 0 390 s 417 MB 187, the same

Not in this change

Other constructs of the inspection remain quadratic on hostile input. Each one still ends:

Input 100,000 characters 1,000,000 characters
< not followed by a letter, repeated 0.2 s 15 s
unclosed end tags (</a) 1.3 s over 60 s
unclosed comments (<!--) 8 s over 60 s
unclosed <script> blocks 5 s over 60 s

They 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.

_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.
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Oct 21, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Oct 21, 2026
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.
@SizzleUnrlsd
SizzleUnrlsd merged commit 40a501a into main Oct 21, 2026
10 checks passed
@SizzleUnrlsd
SizzleUnrlsd deleted the fix/template-start-tags branch October 21, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

templates: _START_TAG backtracks exponentially, a file under templates hangs --check

1 participant