From 9225a67dee16d6908b9832491e8ecfb496837086 Mon Sep 17 00:00:00 2001 From: hong4rc Date: Sun, 12 Jul 2026 11:36:25 +0700 Subject: [PATCH 1/2] fix: Avoid O(n^2) backtracking in HTML block close regex The close branches end in `[^\n]*\n+`; the trailing `\n+` requires a newline, so at EOF the close can't match and the engine retries every split of the lazy `[\s\S]*?` before falling through to `$`, which is O(n^2). `\n*` closes on first match and consumes identical text whenever a trailing newline is present (the `[^\n]*` was added in #3991). --- src/rules.ts | 8 ++++---- test/specs/redos/quadratic_html_block_close.cjs | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 test/specs/redos/quadratic_html_block_close.cjs diff --git a/src/rules.ts b/src/rules.ts index cdbbeadb93..4616e4634f 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -149,11 +149,11 @@ const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' const _comment = /|$))/; const html = edit( '^ {0,3}(?:' // optional indentation -+ '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) ++ '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n*|$)' // (1) + '|comment[^\\n]*(\\n+|$)' // (2) -+ '|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n+|$)' // (3) -+ '|[^\\n]*\\n+|$)' // (4) -+ '|[^\\n]*\\n+|$)' // (5) ++ '|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n*|$)' // (3) ++ '|[^\\n]*\\n*|$)' // (4) ++ '|[^\\n]*\\n*|$)' // (5) + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag diff --git a/test/specs/redos/quadratic_html_block_close.cjs b/test/specs/redos/quadratic_html_block_close.cjs new file mode 100644 index 0000000000..b5c753f6df --- /dev/null +++ b/test/specs/redos/quadratic_html_block_close.cjs @@ -0,0 +1,4 @@ +module.exports = { + markdown: ''.repeat(50000), + html: ''.repeat(50000), +}; From 0e3273030c26f9831f4905156f96b81d17dce695 Mon Sep 17 00:00:00 2001 From: hong4rc Date: Sun, 12 Jul 2026 11:36:53 +0700 Subject: [PATCH 2/2] fix: Avoid O(n^2) backtracking in tilde paragraph interrupt regex The backtick branch is guarded by a lookahead but `~{3,}` isn't, and it overlaps the following `[^\n]*`, so a long newline-less tilde run backtracks quadratically. Since `~{3,}` is always followed by `[^\n]*`, `~~~` matches the same strings without the overlap. The real fences tokenizer is left untouched. --- src/rules.ts | 6 +++--- test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs diff --git a/src/rules.ts b/src/rules.ts index 4616e4634f..2c08276fc8 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -169,7 +169,7 @@ const createParagraph = (listInterrupt: RegExp) => edit(_paragraph) .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs .replace('|table', '') .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n') .replace('list', listInterrupt) .replace('html', ')|<(?:script|pre|style|textarea|!--)') .replace('tag', _tag) // pars can be interrupted by type (6) html blocks @@ -218,7 +218,7 @@ const gfmTable = edit( .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') .replace('blockquote', ' {0,3}>') .replace('code', '(?: {4}| {0,3}\t)[^\\n]') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n') .replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]') // any bullet ends the table rows .replace('html', ')|<(?:script|pre|style|textarea|!--)') .replace('tag', _tag) // tables can be interrupted by type (6) html blocks @@ -234,7 +234,7 @@ const blockGfm: Record = { .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs .replace('table', gfmTable) // interrupt paragraphs with table .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n') .replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]') // only non-empty lists starting from 1 can interrupt .replace('html', ')|<(?:script|pre|style|textarea|!--)') .replace('tag', _tag) // pars can be interrupted by type (6) html blocks diff --git a/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs b/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs new file mode 100644 index 0000000000..b9e9c4c096 --- /dev/null +++ b/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs @@ -0,0 +1,4 @@ +module.exports = { + markdown: 'intro\n' + '~'.repeat(50000), + html: `

intro\n${'~'.repeat(50000)}

\n`, +};