From e264eca0b5a285672cab94457de2fba7bd89207c Mon Sep 17 00:00:00 2001 From: honor00 Date: Sun, 9 Aug 2026 19:04:02 +0800 Subject: [PATCH 1/3] fix: Inline footnote parsing does O Inline footnote parsing does O(n) list membership per reference (quadratic DoS on n footnote references) Defect id: footnote-defs-membership-quadratic Verified against upstream tag v3.3.4 and HEAD (triage: unfixed_at_head). Generated-by: blackhole-agent upstream-publication plane (autonomous stewardship mission) --- src/mistune/plugins/footnotes.py | 2 +- tests/footnote_defs_quadratic.py | 63 +++++++++++++++++++ ...tion_footnote_defs_membership_quadratic.py | 17 +++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/footnote_defs_quadratic.py create mode 100644 tests/test_contribution_footnote_defs_membership_quadratic.py 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/footnote_defs_quadratic.py b/tests/footnote_defs_quadratic.py new file mode 100644 index 0000000..2cafd97 --- /dev/null +++ b/tests/footnote_defs_quadratic.py @@ -0,0 +1,63 @@ +"""Synthesized standalone repro for the footnote_defs defect (complexity). + +Discovered autonomously by blackhole_agent.upstream_discovery. Runs a doubling +ladder against the source tree given as argv[1]; exits 1 while the defect is +present, 0 once repaired. Usage: python +""" +import json, math, sys, time + +sys.path.insert(0, sys.argv[1]) +PLUGINS = ['footnotes'] +KIND = 'complexity' +EXPONENT_THRESHOLD = 1.75 +TIME_FLAG_FLOOR = 0.3 + +def gen(n): + refs = ''.join('[^%d] ' % i for i in range(n)) + defs = '\n'.join('[^%d]: x' % i for i in range(n)) + return refs + '\n\n' + defs + +import mistune + +_RENDERERS = {} + +def render(text, plugins): + key = tuple(plugins) + md = _RENDERERS.get(key) + if md is None: + md = mistune.create_markdown(plugins=list(plugins)) + _RENDERERS[key] = md + md(text) + +render('warmup', PLUGINS) +n = 16000 +times = [] +crashed = None +limit = max(16000 * 4, 16000 + 1) +while n <= limit: + text = gen(n) + t0 = time.perf_counter() + try: + render(text, PLUGINS) + elapsed = time.perf_counter() - t0 + except Exception as e: + crashed = type(e).__name__ + break + times.append((n, elapsed)) + if elapsed >= 1.0 and len(times) >= 2: + # Always measure at least two sizes: one load-inflated run must not + # end the ladder before any growth pair exists. + break + n *= 2 + +if crashed is not None: + print(json.dumps({'defect': True, 'kind': KIND, 'crash': crashed})) + sys.exit(1) +worst = 0.0 +for (n1, t1), (n2, t2) in zip(times, times[1:]): + if t1 >= 0.02 and n2 > n1: + worst = max(worst, math.log2(max(t2, 1e-9) / t1) / math.log2(n2 / n1)) +t_max = max((t for _, t in times), default=0.0) +defect = worst >= EXPONENT_THRESHOLD and t_max >= TIME_FLAG_FLOOR +print(json.dumps({'defect': defect, 'kind': KIND, 'exponent': round(worst, 3), 't_max': round(t_max, 4)})) +sys.exit(1 if defect else 0) diff --git a/tests/test_contribution_footnote_defs_membership_quadratic.py b/tests/test_contribution_footnote_defs_membership_quadratic.py new file mode 100644 index 0000000..3ceae31 --- /dev/null +++ b/tests/test_contribution_footnote_defs_membership_quadratic.py @@ -0,0 +1,17 @@ +"""Regression test synthesized by blackhole_agent.upstream_contribution. + +Runs the minimized standalone repro for defect footnote-defs-membership-quadratic against the +patched source tree; the repro exits 0 only once the defect is repaired. +""" + +import subprocess +import sys +from pathlib import Path + +REPRO = Path(__file__).resolve().parent / 'footnote_defs_quadratic.py' +SRC = Path(__file__).resolve().parents[1] / 'src' + + +def test_footnote_defs_membership_quadratic_regression() -> None: + proc = subprocess.run([sys.executable, str(REPRO), str(SRC)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr[-400:] From 27f81327fb7dc9159b7e9884f462237b8802fad5 Mon Sep 17 00:00:00 2001 From: honor00 Date: Fri, 28 Aug 2026 10:27:22 +0800 Subject: [PATCH 2/3] [projection-agent] Address PR review feedback --- tests/footnote_defs_quadratic.py | 5 ++++- ..._contribution_footnote_defs_membership_quadratic.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/footnote_defs_quadratic.py b/tests/footnote_defs_quadratic.py index 2cafd97..df7cf7d 100644 --- a/tests/footnote_defs_quadratic.py +++ b/tests/footnote_defs_quadratic.py @@ -4,7 +4,10 @@ ladder against the source tree given as argv[1]; exits 1 while the defect is present, 0 once repaired. Usage: python """ -import json, math, sys, time +import json +import math +import sys +import time sys.path.insert(0, sys.argv[1]) PLUGINS = ['footnotes'] diff --git a/tests/test_contribution_footnote_defs_membership_quadratic.py b/tests/test_contribution_footnote_defs_membership_quadratic.py index 3ceae31..347e6b5 100644 --- a/tests/test_contribution_footnote_defs_membership_quadratic.py +++ b/tests/test_contribution_footnote_defs_membership_quadratic.py @@ -13,5 +13,11 @@ def test_footnote_defs_membership_quadratic_regression() -> None: - proc = subprocess.run([sys.executable, str(REPRO), str(SRC)], capture_output=True, text=True) - assert proc.returncode == 0, proc.stderr[-400:] + proc = subprocess.run( + [sys.executable, str(REPRO), str(SRC)], + capture_output=True, + text=True, + timeout=30, + ) + diagnostics = f"stdout:\n{proc.stdout[-400:]}\nstderr:\n{proc.stderr[-400:]}" + assert proc.returncode == 0, diagnostics From 7e0fd19aa6e0f4f7bfea0430a23da1f8e3182438 Mon Sep 17 00:00:00 2001 From: honor00 Date: Mon, 31 Aug 2026 15:37:06 +0800 Subject: [PATCH 3/3] test: make footnote complexity regression deterministic --- tests/footnote_defs_quadratic.py | 66 ------------------- ...tion_footnote_defs_membership_quadratic.py | 23 ------- tests/test_security_footnotes.py | 36 ++++++++++ 3 files changed, 36 insertions(+), 89 deletions(-) delete mode 100644 tests/footnote_defs_quadratic.py delete mode 100644 tests/test_contribution_footnote_defs_membership_quadratic.py create mode 100644 tests/test_security_footnotes.py diff --git a/tests/footnote_defs_quadratic.py b/tests/footnote_defs_quadratic.py deleted file mode 100644 index df7cf7d..0000000 --- a/tests/footnote_defs_quadratic.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Synthesized standalone repro for the footnote_defs defect (complexity). - -Discovered autonomously by blackhole_agent.upstream_discovery. Runs a doubling -ladder against the source tree given as argv[1]; exits 1 while the defect is -present, 0 once repaired. Usage: python -""" -import json -import math -import sys -import time - -sys.path.insert(0, sys.argv[1]) -PLUGINS = ['footnotes'] -KIND = 'complexity' -EXPONENT_THRESHOLD = 1.75 -TIME_FLAG_FLOOR = 0.3 - -def gen(n): - refs = ''.join('[^%d] ' % i for i in range(n)) - defs = '\n'.join('[^%d]: x' % i for i in range(n)) - return refs + '\n\n' + defs - -import mistune - -_RENDERERS = {} - -def render(text, plugins): - key = tuple(plugins) - md = _RENDERERS.get(key) - if md is None: - md = mistune.create_markdown(plugins=list(plugins)) - _RENDERERS[key] = md - md(text) - -render('warmup', PLUGINS) -n = 16000 -times = [] -crashed = None -limit = max(16000 * 4, 16000 + 1) -while n <= limit: - text = gen(n) - t0 = time.perf_counter() - try: - render(text, PLUGINS) - elapsed = time.perf_counter() - t0 - except Exception as e: - crashed = type(e).__name__ - break - times.append((n, elapsed)) - if elapsed >= 1.0 and len(times) >= 2: - # Always measure at least two sizes: one load-inflated run must not - # end the ladder before any growth pair exists. - break - n *= 2 - -if crashed is not None: - print(json.dumps({'defect': True, 'kind': KIND, 'crash': crashed})) - sys.exit(1) -worst = 0.0 -for (n1, t1), (n2, t2) in zip(times, times[1:]): - if t1 >= 0.02 and n2 > n1: - worst = max(worst, math.log2(max(t2, 1e-9) / t1) / math.log2(n2 / n1)) -t_max = max((t for _, t in times), default=0.0) -defect = worst >= EXPONENT_THRESHOLD and t_max >= TIME_FLAG_FLOOR -print(json.dumps({'defect': defect, 'kind': KIND, 'exponent': round(worst, 3), 't_max': round(t_max, 4)})) -sys.exit(1 if defect else 0) diff --git a/tests/test_contribution_footnote_defs_membership_quadratic.py b/tests/test_contribution_footnote_defs_membership_quadratic.py deleted file mode 100644 index 347e6b5..0000000 --- a/tests/test_contribution_footnote_defs_membership_quadratic.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Regression test synthesized by blackhole_agent.upstream_contribution. - -Runs the minimized standalone repro for defect footnote-defs-membership-quadratic against the -patched source tree; the repro exits 0 only once the defect is repaired. -""" - -import subprocess -import sys -from pathlib import Path - -REPRO = Path(__file__).resolve().parent / 'footnote_defs_quadratic.py' -SRC = Path(__file__).resolve().parents[1] / 'src' - - -def test_footnote_defs_membership_quadratic_regression() -> None: - proc = subprocess.run( - [sys.executable, str(REPRO), str(SRC)], - capture_output=True, - text=True, - timeout=30, - ) - diagnostics = f"stdout:\n{proc.stdout[-400:]}\nstderr:\n{proc.stderr[-400:]}" - assert proc.returncode == 0, diagnostics 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}}], + )