diff --git a/src/mistune/plugins/footnotes.py b/src/mistune/plugins/footnotes.py index 9c9b0c5..8783304 100644 --- a/src/mistune/plugins/footnotes.py +++ b/src/mistune/plugins/footnotes.py @@ -38,7 +38,7 @@ def parse_inline_footnote(inline: "InlineParser", m: Match[str], state: "InlineS if not indexes: indexes = {note_key: index for index, note_key in enumerate(notes)} state.env["footnote_indexes"] = indexes - if key not in notes: + if key not in indexes: notes.append(key) indexes[key] = len(notes) - 1 state.env["footnotes"] = notes diff --git a/tests/test_security_footnotes.py b/tests/test_security_footnotes.py new file mode 100644 index 0000000..06e999c --- /dev/null +++ b/tests/test_security_footnotes.py @@ -0,0 +1,36 @@ +import re +from unittest import TestCase + +from mistune.core import InlineState +from mistune.inline_parser import InlineParser +from mistune.plugins.footnotes import INLINE_FOOTNOTE, parse_inline_footnote + + +class _NoMembershipList(list[str]): + def __contains__(self, item: object) -> bool: + raise AssertionError("footnote membership must use footnote_indexes") + + +class TestFootnoteSecurity(TestCase): + def test_inline_footnote_membership_uses_index_mapping(self): + notes = _NoMembershipList(["FIRST"]) + indexes = {"FIRST": 0} + state = InlineState( + { + "ref_footnotes": {"FIRST": "First note", "SECOND": "Second note"}, + "footnotes": notes, + "footnote_indexes": indexes, + } + ) + match = re.match(INLINE_FOOTNOTE, "[^second]") + self.assertIsNotNone(match) + assert match is not None + + parse_inline_footnote(InlineParser(), match, state) + + self.assertEqual(notes, ["FIRST", "SECOND"]) + self.assertEqual(indexes, {"FIRST": 0, "SECOND": 1}) + self.assertEqual( + state.tokens, + [{"type": "footnote_ref", "raw": "SECOND", "attrs": {"index": 2}}], + )