Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 3 additions & 1 deletion mdit_py_plugins/admon/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<tokens>[^"]+)\s+"(?P<title>.*)"\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(" ")
Expand Down
10 changes: 6 additions & 4 deletions mdit_py_plugins/texmath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
KyleKing marked this conversation as resolved.
"tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n",
"tag": "```math",
},
Expand Down Expand Up @@ -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",
},
Expand Down
164 changes: 164 additions & 0 deletions tests/test_redos.py
Original file line number Diff line number Diff line change
@@ -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)"
)
15 changes: 15 additions & 0 deletions tests/test_texmath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading