Skip to content

Commit c4093c4

Browse files
committed
fix(toc): avoid generated id collisions
1 parent e3e51de commit c4093c4

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/mistune/toc.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
23

34
from .core import BlockState
@@ -6,6 +7,8 @@
67
if TYPE_CHECKING:
78
from .markdown import Markdown
89

10+
_HTML_ID_RE = re.compile(r"""\bid\s*=\s*(?:"([^"]*)"|'([^']*)')""", re.I)
11+
912

1013
def add_toc_hook(
1114
md: "Markdown",
@@ -32,12 +35,17 @@ def add_toc_hook(
3235
:param heading_id: a function to generate heading_id
3336
"""
3437
if heading_id is None:
38+
auto_heading_id = True
3539

3640
def heading_id(token: Dict[str, Any], index: int) -> str:
3741
return "toc_" + str(index + 1)
3842

43+
else:
44+
auto_heading_id = False
45+
3946
def toc_hook(md: "Markdown", state: "BlockState") -> None:
4047
headings = []
48+
used_ids = _find_html_ids(state.src)
4149

4250
for tok in state.tokens:
4351
if tok["type"] == "heading":
@@ -47,7 +55,11 @@ def toc_hook(md: "Markdown", state: "BlockState") -> None:
4755

4856
toc_items = []
4957
for i, tok in enumerate(headings):
50-
tok["attrs"]["id"] = heading_id(tok, i)
58+
_id = heading_id(tok, i)
59+
if auto_heading_id:
60+
_id = _unique_id(_id, used_ids)
61+
used_ids.add(_id)
62+
tok["attrs"]["id"] = _id
5163
toc_items.append(normalize_toc_item(md, tok, parent=state))
5264

5365
# save items into state
@@ -56,6 +68,22 @@ def toc_hook(md: "Markdown", state: "BlockState") -> None:
5668
md.before_render_hooks.append(toc_hook)
5769

5870

71+
def _find_html_ids(src: str) -> set:
72+
return {m.group(1) or m.group(2) for m in _HTML_ID_RE.finditer(src)}
73+
74+
75+
def _unique_id(value: str, used_ids: set) -> str:
76+
if value not in used_ids:
77+
return value
78+
79+
i = 1
80+
while True:
81+
new_value = value + "_" + str(i)
82+
if new_value not in used_ids:
83+
return new_value
84+
i += 1
85+
86+
5987
def normalize_toc_item(md: "Markdown", token: Dict[str, Any], parent: Optional[Any] = None) -> Tuple[int, str, str]:
6088
text = token["text"]
6189
tokens = md.inline(text, parent.env if parent else {})

tests/test_security_toc.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from unittest import TestCase
2+
3+
from mistune import create_markdown
4+
from mistune.toc import add_toc_hook, render_toc_ul
5+
6+
7+
class TestTocSecurity(TestCase):
8+
def test_custom_heading_id_is_escaped(self):
9+
md = create_markdown(escape=True)
10+
add_toc_hook(md, heading_id=lambda token, index: token.get("text", ""))
11+
12+
html, _state = md.parse('## foo" onmouseover="alert(1)" x="\n')
13+
14+
self.assertIn('id="foo" onmouseover="alert(1)" x=""', html)
15+
self.assertNotIn('onmouseover="alert(1)"', html)
16+
17+
def test_toc_href_is_escaped(self):
18+
md = create_markdown(escape=True)
19+
add_toc_hook(md, heading_id=lambda token, index: token.get("text", ""))
20+
21+
_html, state = md.parse('## x"><script>alert(1)</script><a href="\n')
22+
toc = render_toc_ul(state.env["toc_items"])
23+
24+
self.assertIn('href="#x&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;a href=&quot;"', toc)
25+
self.assertNotIn("<script>", toc)
26+
27+
def test_default_toc_id_avoids_existing_html_id_collision(self):
28+
md = create_markdown(escape=False)
29+
add_toc_hook(md)
30+
31+
html, state = md.parse('<div id="toc_1"></div>\n\n# title\n')
32+
toc = render_toc_ul(state.env["toc_items"])
33+
34+
self.assertIn('<h1 id="toc_1_1">title</h1>', html)
35+
self.assertIn('href="#toc_1_1"', toc)

0 commit comments

Comments
 (0)