Skip to content

Commit 25f2503

Browse files
committed
fix(inline): avoid bracket parsing DoS
1 parent 1bef343 commit 25f2503

2 files changed

Lines changed: 69 additions & 4 deletions

File tree

‎src/mistune/inline_parser.py‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,12 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
192192
def _contains_nested_link(self, text: str, state: InlineState) -> bool:
193193
if "[" not in text:
194194
return False
195+
if "](" not in text and "][" not in text and not state.env.get("ref_links"):
196+
return False
195197

196198
sc = self.compile_sc(["link"])
199+
nested_state = state.copy()
200+
nested_state.src = text
197201
pos = 0
198202
while pos < len(text):
199203
m = sc.search(text, pos)
@@ -202,10 +206,7 @@ def _contains_nested_link(self, text: str, state: InlineState) -> bool:
202206

203207
marker = m.group(0)
204208
if marker == "[" and (m.start() == 0 or text[m.start() - 1] != "!"):
205-
nested_state = state.copy()
206-
nested_state.src = text
207-
new_pos = self.parse_link(m, nested_state)
208-
if new_pos and any(token["type"] == "link" for token in nested_state.tokens):
209+
if _is_link_like(text, m.end(), nested_state):
209210
return True
210211
pos = m.start() + 1
211212

@@ -803,6 +804,33 @@ def _find_closing_bracket(state: InlineState, pos: int) -> Optional[int]:
803804
return pairs.get(pos)
804805

805806

807+
def _is_link_like(src: str, pos: int, state: InlineState) -> bool:
808+
label, end_pos = parse_link_label(src, pos)
809+
if label is None:
810+
label, end_pos = _parse_link_text(state, pos)
811+
if label is None:
812+
return False
813+
814+
if end_pos >= len(src):
815+
ref_links = state.env.get("ref_links")
816+
return bool(ref_links and unikey(label) in ref_links)
817+
818+
marker = src[end_pos]
819+
if marker == "(":
820+
_attrs, new_pos = parse_link(src, end_pos + 1)
821+
return bool(new_pos)
822+
823+
if marker == "[":
824+
label2, new_pos = parse_link_label(src, end_pos + 1)
825+
if not new_pos:
826+
return False
827+
if label2:
828+
label = label2
829+
830+
ref_links = state.env.get("ref_links")
831+
return bool(ref_links and unikey(label) in ref_links)
832+
833+
806834
def _build_closing_bracket_map(src: str) -> Dict[int, int]:
807835
pairs: Dict[int, int] = {}
808836
stack: List[int] = []

‎tests/test_security_inline.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import time
2+
from unittest import TestCase
3+
4+
from mistune import create_markdown
5+
6+
7+
class TestInlineParserSecurity(TestCase):
8+
def test_link_title_redos_payload_returns_quickly(self):
9+
md = create_markdown()
10+
payload = '[x](y "' + "\\!" * 24 + ")"
11+
12+
start = time.monotonic()
13+
html = md(payload)
14+
elapsed = time.monotonic() - start
15+
16+
self.assertLess(elapsed, 0.5)
17+
self.assertIn("[x](y", html)
18+
19+
def test_nested_bracket_link_input_does_not_recurse_forever(self):
20+
text = "[" * 200 + "x" + "]" * 200
21+
22+
start = time.monotonic()
23+
html = create_markdown()(text)
24+
elapsed = time.monotonic() - start
25+
26+
self.assertLess(elapsed, 0.5)
27+
self.assertIn("[", html)
28+
29+
def test_unmatched_bracket_input_returns_quickly(self):
30+
text = "[" * 4000
31+
32+
start = time.monotonic()
33+
html = create_markdown()(text)
34+
elapsed = time.monotonic() - start
35+
36+
self.assertLess(elapsed, 0.5)
37+
self.assertIn("[", html)

0 commit comments

Comments
 (0)