Skip to content

fix: avoid O(n^2) scanning in reflinkSearch - #4090

Merged
UziTech merged 1 commit into
markedjs:masterfrom
spokodev:fix/reflink-search-quadratic
Sep 11, 2026
Merged

fix: avoid O(n^2) scanning in reflinkSearch#4090
UziTech merged 1 commit into
markedjs:masterfrom
spokodev:fix/reflink-search-quadratic

Conversation

@spokodev

Copy link
Copy Markdown
Contributor

Marked version: current master (a43c064); reproduces on the released 18.0.12

Markdown flavor: CommonMark

Description

With default options, a run of escaped brackets parses in O(n²). The input is just text — it contains no ] at all, so nothing in it can ever become a link:

marked.parse('[' + '\t\\['.repeat(n) + '[');
input master this PR
23 KB 0.34 s 0.14 s
94 KB 5.16 s 0.57 s
188 KB 20.9 s 1.09 s

Doubling the input quadruples the time on master. After the change it doubles it, measured out to 1.5 MB (×1.94 to ×2.02 per doubling). For scale, 190 KB of ordinary markdown parses in 50 ms.

The cost is inline.reflinkSearch. reflink and nolink are anchored, so the tokenizer tries each of them at a single position, but reflinkSearch drops the anchors and runs with the global flag — every [ in the source becomes a start position. A label crosses a bracket only by escaping it, and nothing capped how often it could, so a candidate that can never match still scans to the end of the source. Isolating the rule at n=32,000 gives 3.3 s for zero matches.

The fix bounds the label runs that reflinkSearch is built from, leaving reflink, nolink and link untouched:

  • _boundedInlineLabel bounds how many escapes, code spans and nested brackets a link text may hold. Runs of ordinary characters stay unbounded, so link text of any length is still found — CommonMark puts no limit on link text, only on link labels.
  • _boundedBlockLabel bounds the label to 999 items, which covers every label the spec permits: "A link label can have at most 999 characters inside the square brackets."

Verification

test:specs 1813/1813, test:unit 191/191, test:umd, test:cjs, test:types, test:lint all pass.

To check that nothing else moved, I diffed master's output against this branch over 247,768 document × option comparisons — 421 real-world .md files, all 1,521 markdown samples in test/specs, and 60,000 seeded bracket/escape/backtick fuzz cases, each under default, gfm: false, breaks and pedantic. Zero output differences. Shrinking the bounds to 3 makes that same harness report 24 differences and 10 spec failures, so it does detect change.

Output does change for one input class: a reference label longer than 999 characters. master accepts it as a link, this branch does not — which is what commonmark.js does:

label length commonmark.js marked master this PR
904 link link link
1003 text link text
2004 text link text

One note on tooling: recheck reports this rule as vulnerable both before and after, because a bound of 999 is still a large constant. It also flags inline.normal.link and inline.gfm._backpedal, which measure as fast end to end, so I went by measured parse time rather than by its verdict.

test/specs/redos/quadratic_reflink_search.cjs sits next to the existing quadratic guards. With the fix its two cases take 0.32 s and 0.23 s; with src/rules.ts reverted they take 1.63 s and 1.65 s and both fail the runner's took too long assertion.

Contributor

  • Test(s) exist to ensure functionality and minimize regression: test/specs/redos/quadratic_reflink_search.cjs.
  • no tests required for this PR.
  • If submitting new feature, it has been documented in the appropriate places.

🤖 Generated with Claude Code

`reflink` and `nolink` are anchored, so the tokenizer tries each of them
at a single position. `reflinkSearch` drops the anchors and runs with the
global flag, which makes every '[' in the source a start position. A label
crosses a bracket only by escaping it, and nothing capped how often it
could, so a candidate that can never match still scanned to the end of the
source: 188 KB of `'[' + '\t\\['.repeat(n)` took 21s with default options.

Bound the label runs used to build `reflinkSearch`. The inline label limits
how many escapes, code spans and nested brackets a link text may hold and
leaves runs of ordinary characters unbounded, so link text of any length is
still found. The block label is limited to 999 items, which covers every
label CommonMark allows.

Parse time is now linear: 188 KB goes from 20.9s to 1.1s, and doubling the
input doubles the time instead of quadrupling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

@spokodev is attempting to deploy a commit to the MarkedJS Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marked-website Ready Ready Preview Sep 11, 2026 5:29am UTC

Request Review

@UziTech UziTech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! 💯

I didn't know CommonMark limited reflink length

@UziTech
UziTech merged commit c6a25bb into markedjs:master Sep 11, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants