Skip to content

Commit be6367a

Browse files
authored
👌 Reject unterminated inline HTML in constant time (#428)
1 parent a5950ca commit be6367a

3 files changed

Lines changed: 127 additions & 4 deletions

File tree

‎markdown_it/rules_inline/html_inline.py‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,50 @@ def html_inline(state: StateInline, silent: bool) -> bool:
1717
return False
1818

1919
# Check start
20+
src = state.src
2021
maximum = state.posMax
21-
if state.src[pos] != "<" or pos + 2 >= maximum:
22+
if src[pos] != "<" or pos + 2 >= maximum:
2223
return False
2324

2425
# Quick fail on second char
25-
ch = state.src[pos + 1]
26+
ch = src[pos + 1]
2627
if ch not in ("!", "?", "/") and not isLetter(ord(ch)): # /* / */
2728
return False
2829

29-
match = HTML_TAG_RE.match(state.src, pos)
30+
# Every alternative of the tag regex ends with a specific terminator, at a
31+
# known minimum offset from `pos`. If that terminator does not occur
32+
# anywhere at or after this offset, no match is possible; bail out before
33+
# running the regex, whose lazy sub-patterns would scan to the end of the
34+
# input. Without this, a run of unterminated openers is quadratic.
35+
if src.startswith("<!--", pos):
36+
# `<!-->` / `<!--->`, else `<!--` ... `-->` (`<!---->` is the shortest)
37+
if state.html_terminator_last(">") < pos + 4:
38+
return False
39+
if (
40+
not src.startswith("<!-->", pos)
41+
and not src.startswith("<!--->", pos)
42+
and state.html_terminator_last("-->") < pos + 4
43+
):
44+
return False
45+
elif src.startswith("<![CDATA[", pos):
46+
if state.html_terminator_last("]]>") < pos + 9: # `<![CDATA[]]>`
47+
return False
48+
elif ch == "?":
49+
if state.html_terminator_last("?>") < pos + 2: # `<??>`
50+
return False
51+
elif ch == "!":
52+
if state.html_terminator_last(">") < pos + 3: # `<!a>`
53+
return False
54+
elif state.html_terminator_last(">") < pos + 2: # `<a>`, `</a>`
55+
return False
56+
57+
match = HTML_TAG_RE.match(src, pos)
3058
if not match:
3159
return False
3260

3361
if not silent:
3462
token = state.push("html_inline", "", 0)
35-
token.content = state.src[pos : pos + len(match.group(0))]
63+
token.content = src[pos : pos + len(match.group(0))]
3664

3765
if isLinkOpen(token.content):
3866
state.linkLevel += 1

‎markdown_it/rules_inline/state_inline.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ def __init__(
8484
# inside <a> and markdown links
8585
self.linkLevel = 0
8686

87+
# Lazy cache of `terminator -> last index in src`, see
88+
# `html_terminator_last`.
89+
self._html_terminators: dict[str, int] | None = None
90+
91+
def html_terminator_last(self, term: str) -> int:
92+
"""Index of the last occurrence of `term` in `self.src`, or -1.
93+
94+
The result is cached per terminator, for the life of the state.
95+
It is used by the `html_inline` rule to reject, in constant time, a
96+
position at which the terminator required to close an HTML construct
97+
cannot possibly occur -- without running the tag regex, whose lazy
98+
sub-patterns would otherwise rescan to the end of the input on every
99+
such (failing) attempt.
100+
"""
101+
if self._html_terminators is None:
102+
self._html_terminators = {}
103+
elif (last := self._html_terminators.get(term)) is not None:
104+
return last
105+
last = self._html_terminators[term] = self.src.rfind(term)
106+
return last
107+
87108
def __repr__(self) -> str:
88109
return (
89110
f"{self.__class__.__name__}"

‎tests/test_html_inline.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests for the `html_inline` rule."""
2+
3+
import importlib
4+
5+
import pytest
6+
7+
from markdown_it import MarkdownIt
8+
9+
10+
@pytest.mark.parametrize(
11+
"src",
12+
[
13+
"<a>", # open tag
14+
"<a/>", # self-closing open tag
15+
"</a>", # close tag
16+
"<!-->", # short comment
17+
"<!--->", # short comment
18+
"<!---->", # shortest full comment
19+
"<??>", # empty processing instruction
20+
"<!a>", # declaration
21+
"<![CDATA[]]>", # empty CDATA section
22+
],
23+
)
24+
def test_shortest_constructs(src):
25+
"""The shortest legal form of each construct is passed through as raw html."""
26+
assert MarkdownIt("commonmark").renderInline(src) == src
27+
28+
29+
@pytest.mark.parametrize("opener", ["<![CDATA[", "<!--", "<?", "<!a", "<a", "</a"])
30+
def test_unterminated_openers_skip_the_regex(opener, monkeypatch):
31+
"""A run of openers that are never terminated must not run the tag regex.
32+
33+
Every alternative of ``HTML_TAG_RE`` ends in a specific terminator, and its
34+
lazy sub-patterns rescan to the end of the input on each failed attempt,
35+
which made such runs quadratic. ``html_inline`` now rejects an opener in
36+
constant time when the terminator it needs cannot occur, so the regex is
37+
never invoked here; before the fix it ran once per opener.
38+
"""
39+
module = importlib.import_module("markdown_it.rules_inline.html_inline")
40+
41+
real = module.HTML_TAG_RE
42+
calls = []
43+
44+
class Counting:
45+
def match(self, *args, **kwargs):
46+
calls.append(args[1] if len(args) > 1 else None)
47+
return real.match(*args, **kwargs)
48+
49+
monkeypatch.setattr(module, "HTML_TAG_RE", Counting())
50+
src = "x" + opener * 1_000 # leading "x" keeps it out of `html_block`
51+
html = MarkdownIt("commonmark").render(src)
52+
assert calls == []
53+
# the openers all render as escaped text
54+
assert html == "<p>x" + (opener.replace("<", "&lt;")) * 1_000 + "</p>\n"
55+
56+
57+
def test_terminated_constructs_still_reach_the_regex(monkeypatch):
58+
"""The quick-reject must be exactly that: a terminated construct is still
59+
handed to the regex and parsed as before."""
60+
module = importlib.import_module("markdown_it.rules_inline.html_inline")
61+
62+
real = module.HTML_TAG_RE
63+
calls = []
64+
65+
class Counting:
66+
def match(self, *args, **kwargs):
67+
calls.append(1)
68+
return real.match(*args, **kwargs)
69+
70+
monkeypatch.setattr(module, "HTML_TAG_RE", Counting())
71+
src = "x <!-- c --> <?pi?> <![CDATA[d]]> <!D> <a>b</a>"
72+
html = MarkdownIt("commonmark").render(src)
73+
assert calls, "expected the regex to run for terminated constructs"
74+
assert html == "<p>" + src + "</p>\n"

0 commit comments

Comments
 (0)