diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b78b3..92c8da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Change Log +## Unreleased + +- 🐛 FIX: Resolve regex denial-of-service (ReDoS) in `texmath` and `admon` (#143, #148) + + Two shipped patterns backtracked super-linearly on adversarial input with no + closing delimiter, so a single crafted document could hang the parser: + + - `texmath`'s `` ```math `` block rules (all `` ```math `` flavors, i.e. the + `gitlab` and `julia` delimiters) blew up on a `` ```math `` fence followed by a + long whitespace run with no closing fence. + - `admon`'s multi-tag title parser blew up on an unquoted header containing a long + interior whitespace run. + + Both patterns were rewritten to anchor the capture on non-whitespace at both ends, + which stays flat on the same input. A behavioral guard (`tests/test_redos.py`) now + renders adversarial payloads through every plugin to catch the next such pattern. + ## 0.7.0 - 2026-07-19 - ✨ NEW: Add section reference plugin (`section_ref`) (#144) diff --git a/mdit_py_plugins/admon/index.py b/mdit_py_plugins/admon/index.py index 6e28940..e54c1ae 100644 --- a/mdit_py_plugins/admon/index.py +++ b/mdit_py_plugins/admon/index.py @@ -20,7 +20,9 @@ def _get_multiple_tags(params: str) -> tuple[list[str], str]: """Check for multiple tags when the title is double quoted.""" - re_tags = re.compile(r'^\s*(?P[^"]+)\s+"(?P.*)"\S*$') + re_tags = re.compile( + r'^\s*(?P<tokens>[^"\s](?:[^"]*[^"\s])?)\s+"(?P<title>.*)"\S*$' + ) match = re_tags.match(params) if match: tags = match["tokens"].strip().split(" ") diff --git a/mdit_py_plugins/texmath/index.py b/mdit_py_plugins/texmath/index.py index 67372fa..8bae929 100644 --- a/mdit_py_plugins/texmath/index.py +++ b/mdit_py_plugins/texmath/index.py @@ -228,14 +228,15 @@ def render(tex: str, displayMode: bool, macros: Any) -> str: { "name": "math_block_eqno", "rex": re.compile( - r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + r"^`{3}math\s+([^`\s](?:[^`]*[^`\s])?)\s+`{3}\s*?\(([^)$\r\n]+?)\)", + re.M, ), "tmpl": '<section class="eqno">\n<eqn>{0}</eqn><span>({1})</span>\n</section>\n', "tag": "```math", }, { "name": "math_block", - "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "rex": re.compile(r"^`{3}math\s+([^`\s](?:[^`]*[^`\s])?)\s+`{3}", re.M), "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", "tag": "```math", }, @@ -270,14 +271,15 @@ def render(tex: str, displayMode: bool, macros: Any) -> str: { "name": "math_block_eqno", "rex": re.compile( - r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + r"^`{3}math\s+([^`\s](?:[^`]*[^`\s])?)\s+`{3}\s*?\(([^)$\r\n]+?)\)", + re.M, ), "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', "tag": "```math", }, { "name": "math_block", - "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "rex": re.compile(r"^`{3}math\s+([^`\s](?:[^`]*[^`\s])?)\s+`{3}", re.M), "tmpl": "<section><eqn>{0}</eqn></section>", "tag": "```math", }, diff --git a/tests/test_redos.py b/tests/test_redos.py new file mode 100644 index 0000000..cfc1320 --- /dev/null +++ b/tests/test_redos.py @@ -0,0 +1,164 @@ +"""Behavioral ReDoS guard: render adversarial markdown through each plugin. + +New plugins add regexes over time. Rather than trust review to catch catastrophic +backtracking, this drives each plugin end-to-end (``MarkdownIt().use(plugin)`` then +``md.render(payload)``) against adversarial input and asserts it stays fast. + +Two properties make this faithful to the real attack surface without introspecting +any pattern: + +- The payload embeds a long *homogeneous run* of a single character class, which is + what pushes overlapping or nested quantifiers (e.g. ``\\s+ [^x]+ \\s+``) into + super-linear backtracking. We fuzz every run character, so we never need to know + which regex is vulnerable or how it is structured. +- Rendering exercises the *whole* rule chain (the parser re-invoking a block rule + across offsets), so it also catches the O(n^2) amplification an isolated-regex + check would miss, and never false-positives on a pattern adversarial input can't + actually reach. + +The only per-plugin cost is one hand-written activation payload: the plugin's public +markdown syntax with a ``{run}`` placeholder for the homogeneous run. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import time + +from markdown_it import MarkdownIt +import pytest + +from mdit_py_plugins.admon import admon_plugin +from mdit_py_plugins.amsmath import amsmath_plugin +from mdit_py_plugins.anchors import anchors_plugin +from mdit_py_plugins.colon_fence import colon_fence_plugin +from mdit_py_plugins.deflist import deflist_plugin +from mdit_py_plugins.dollarmath import dollarmath_plugin +from mdit_py_plugins.field_list import fieldlist_plugin +from mdit_py_plugins.footnote import footnote_plugin +from mdit_py_plugins.myst_blocks import myst_block_plugin +from mdit_py_plugins.myst_role import myst_role_plugin +from mdit_py_plugins.tasklists import tasklists_plugin +from mdit_py_plugins.texmath import texmath_plugin + +# Homogeneous runs of one character class are what drive overlapping/nested +# quantifiers into catastrophic backtracking; fuzz every plugin with each +_FILLERS = [" ", "\t", "$", "`", "\\", '"', "-"] +_RUN = 15_000 +_BUDGET_SECONDS = 1.0 # Safe threshold relaxed for CI + + +@dataclass(frozen=True) +class Case: + id: str + configure: Callable[[MarkdownIt], None] + payload: Callable[[str], str] + + +_CASES = [ + Case( + # An interior run with no closing quote: ``[^"]+\s+"`` backtracks the run + # exhaustively looking for a ``"`` that never comes. A trailing run would be + # stripped off before the regex sees it, so the run must sit between tokens. + "admon-header", + lambda md: md.use(admon_plugin), + lambda run: f"!!! note a{run}b\n content", + ), + Case( + "amsmath-open", + lambda md: md.use(amsmath_plugin), + lambda run: f"\\begin{{{run}", + ), + Case( + "amsmath-unclosed", + lambda md: md.use(amsmath_plugin), + lambda run: f"\\begin{{equation}}\n{run}", + ), + Case( + "anchors-heading", + lambda md: md.use(anchors_plugin), + lambda run: f"# {run}", + ), + Case( + "colon-fence", + lambda md: md.use(colon_fence_plugin), + lambda run: f"::: {run}\ncontent\n:::", + ), + Case( + "deflist", + lambda md: md.use(deflist_plugin), + lambda run: f"term\n:{run}", + ), + Case( + "dollarmath-inline", + lambda md: md.use(dollarmath_plugin), + lambda run: f"${run}$", + ), + Case( + "dollarmath-block", + lambda md: md.use(dollarmath_plugin, allow_labels=True), + lambda run: f"$$\n{run}", + ), + Case( + "dollarmath-label", + lambda md: md.use(dollarmath_plugin, allow_labels=True), + lambda run: f"$$a$$ ({run})", + ), + Case( + "fieldlist", + lambda md: md.use(fieldlist_plugin), + lambda run: f":name:{run}", + ), + Case( + "footnote-ref", + lambda md: md.use(footnote_plugin), + lambda run: f"[^{run}]: body", + ), + Case( + "myst-block-target", + lambda md: md.use(myst_block_plugin), + lambda run: f"({run})=", + ), + Case( + "myst-role", + lambda md: md.use(myst_role_plugin), + lambda run: f"{{role}}`{run}`", + ), + Case( + "tasklist", + lambda md: md.use(tasklists_plugin), + lambda run: f"- [ ]{run}x", + ), + Case( + "texmath-fence", + lambda md: md.use(texmath_plugin, delimiters="gitlab"), + lambda run: f"```math\n{run}", + ), + Case( + "texmath-fence-eqno", + lambda md: md.use(texmath_plugin, delimiters="gitlab"), + lambda run: f"```math\n{run}``` (1)", + ), + Case( + "texmath-dollar", + lambda md: md.use(texmath_plugin, delimiters="dollars"), + lambda run: f"${run}$", + ), +] + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.id) +def test_plugin_is_redos_safe(case: Case) -> None: + for filler in _FILLERS: + md = MarkdownIt("commonmark") + case.configure(md) + source = case.payload(filler * _RUN) + start = time.perf_counter() + md.render(source) + elapsed = time.perf_counter() - start + assert elapsed < _BUDGET_SECONDS, ( + f"{case.id} took {elapsed:.3f}s on a {_RUN}x{filler!r} run; " + "likely catastrophic backtracking (ReDoS)" + ) diff --git a/tests/test_texmath.py b/tests/test_texmath.py index 42f7d9b..da601b6 100644 --- a/tests/test_texmath.py +++ b/tests/test_texmath.py @@ -106,3 +106,18 @@ def test_bracket_fixtures(line, title, input, expected): text = md.render(input) print(text) assert text.rstrip() == expected.rstrip() + + +@pytest.mark.parametrize("delimiters", ["gitlab", "julia"]) +def test_fenced_math_block(delimiters): + md = MarkdownIt("commonmark").use(texmath_plugin, delimiters=delimiters) + text = md.render("```math\n\\alpha = 1\n```\n") + assert "<eqn>\\alpha = 1</eqn>" in text + + +@pytest.mark.parametrize("delimiters", ["gitlab", "julia"]) +@pytest.mark.timeout(2) +def test_fenced_math_block_redos(delimiters): + """An unclosed ``` ```math ``` fence must not trigger catastrophic backtracking.""" + md = MarkdownIt("commonmark").use(texmath_plugin, delimiters=delimiters) + md.render("```math\n" + " " * 20000)