Skip to content

emStrong is quadratic in the number of unmatched emphasis delimiters (12 KB input takes ~16 s) #4099

Open

Description

@Darkslayer3324j

Marked version: 18.0.13 (also main at 4eb2d97)

Markdown flavor: n/a (default / GFM)

Description

Parsing time grows quadratically with the number of unmatched * / _ delimiters, so a small, crafted input freezes the parser. A 12 KB document takes about 16-19 seconds on my machine (Windows 11, Node 24; the machine was under some load, so treat absolute numbers loosely, but the growth rate is the point):

import { marked } from 'marked';

for (const unit of ['- *', '+ _', '*x *x ']) {
  for (const n of [500, 1000, 2000, 4000]) {
    const src = unit.repeat(n);
    const t = performance.now();
    marked.parse(src);
    console.log(JSON.stringify(unit), src.length + 'B', Math.round(performance.now() - t) + 'ms');
  }
}
input 1.5-3 KB 3-6 KB 6-12 KB 12 KB
'- *'.repeat(n) (n = 500..4000) 311 ms 1205 ms (x3.9) 5238 ms (x4.3) 19517 ms (x3.7)
'+ _'.repeat(n) 332 ms 995 ms (x3.0) 4001 ms (x4.0) 15726 ms (x3.9)
'*x *x '.repeat(n) (n = 500..2000) 893 ms 4111 ms (x4.6) 15993 ms (x3.9)

Every doubling of the input multiplies the time by about 4. Similar inputs made of 1.*, 1)_, * _, - _ and so on behave the same way, so it is the emphasis handling rather than the list rule. The same shape without list markers (' *_'.repeat(n)) is also quadratic.

Where the time goes

node --cpu-prof on '- *'.repeat(2000): ~2.4 s of ~3.9 s is self time in Tokenizer.emStrong, the rest is GC and the emStrongRDelim* regex. Each unmatched opener runs the while ((match = endReg.exec(maskedSrc)) !== null) loop over all remaining delimiters (adding to delimTotal for every later left delimiter, and doing a [...rDelim].length spread per iteration) and only gives up at the end of the string. With m unmatched delimiters that is m^2 / 2 iterations, and none of the work is shared between openers.

I looked for a small fix and did not find one that is clearly safe: whether a later opener can succeed depends on the running delimTotal (including the % 3 rule and midRun), so failure of an earlier opener does not imply failure of a later one. The usual linear solution is the CommonMark reference algorithm (a delimiter stack with "openers_bottom" per delimiter type/length), which would be a bigger change to emStrong / inlineTokens. If there is a preferred direction (a cheap early-exit such as "no right-flanking delimiter of this kind left in the string", constant-factor cleanups in the loop, or the delimiter-stack approach) I am happy to try a PR.

Expectation

Parse time roughly linear in input size, like other inputs of that size (the 'a_' and plain '*' inputs of the same length parse in a few ms).

Result

Seconds of CPU per 10 KB, growing 4x per doubling.

Related earlier reports were fixed the same way as regex backtracking (#3916, #3917, #4013, #4014, #4090); I did not find an existing issue for emphasis delimiters.

Found by fuzzing marked.parse with generated delimiter patterns and timing each in a worker thread. I drafted this report with AI assistance (Claude Code) and reproduced the numbers above myself.

Activity

  1. UziTech commented on Sep 20, 2026

    @UziTech
    Member

    You should be using workers when parsing untrusted markdown. Then this isn't a problem.

    If you have a fix we are always accepting PRs 😁👍

  2. added
    L2 - annoyingSimilar to L1 - broken but there is a known workaround available for the issue
    on Sep 20, 2026
  3. dyk1454683243-sudo commented on Sep 20, 2026

    @dyk1454683243-sudo

    Taking this — unmatched emphasis delimiters making Tokenizer.emStrong quadratic.

    I did not find an open PR that says Fixes/Closes #4099. I am going to try a small early-exit so a failed opener does not walk every later delimiter when no viable closer remains (and apply the same idea to del if that scan has the same shape). Will keep CommonMark emphasis rules 9–10 and mid-run opener behavior as they are.

  4. dyk1454683243-sudo commented on Sep 20, 2026

    @dyk1454683243-sudo

    Opened #4103 — early-exit for unmatched emStrong / del openers so a failed opener does not re-walk the remainder when no viable closer is left.

  5. mohitjoshi-hey commented on Oct 11, 2026

    @mohitjoshi-hey

    Hey! I had like to work on this issue. Can you assign this to me.

  6. UziTech commented on Oct 11, 2026

    @UziTech
    Member

    @mohitjoshi-hey I'll assign it once there is a PR ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    L2 - annoyingSimilar to L1 - broken but there is a known workaround available for the issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions