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

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
github-actions Bot pushed a commit that referenced this pull request Sep 12, 2026
## [18.0.13](v18.0.12...v18.0.13) (2026-09-12)

### Bug Fixes

* allow tabs in the thematic break that ends a list item ([#4087](#4087)) ([afbb27c](afbb27c))
* avoid O(n^2) scanning in reflinkSearch ([#4090](#4090)) ([c6a25bb](c6a25bb))
* case fold reference link labels ([#4077](#4077)) ([aed9336](aed9336))
* drop the leading whitespace after a hard line break ([#4075](#4075)) ([123ce04](123ce04))
* match html block start conditions when ending a list item ([#4072](#4072)) ([c2facac](c2facac))
* respect raw tokens when closing link labels ([#4066](#4066)) ([ef394f7](ef394f7))
* strip a tab that follows spaces in an indented code block ([#4080](#4080)) ([dbb393d](dbb393d))
HanzoRazer added a commit to HanzoRazer/luthiers-toolbox that referenced this pull request Sep 21, 2026
Both failing checks on PR #390 are the same thing: Dependabot cannot author a
CBSP21 manifest, so the PR had none of its own. The gate says so precisely --
"Manifests exist that would cover these files, but they were authored for other
changes" -- which is CBSP21-NOBORROW-001 doing its job, not a false positive.

Lockfile audited by parsing, not by reading the diff view, because the diff view
is what let the zod 4->3 revert ride along inside PR #296. Loaded both
package-lock.json files and compared the full packages{} map against the merge
base 576aa93:

  412 packages before, 412 after
  ADDED:   none
  REMOVED: none
  CHANGED: node_modules/marked 18.0.12 -> 18.0.13   (only entry)
  SAME VERSION but integrity changed: none

So nothing incidental moved. License unchanged (MIT).

Release content: seven fixes, all CommonMark conformance parsing corrections,
plus markedjs/marked#4090 "avoid O(n^2) scanning in reflinkSearch" -- a
quadratic-blowup hardening that is worth naming because this client renders
server-supplied Markdown. No CVE or GHSA is attached, so this is currency that
happens to include a DoS-shaped fix, not a security update, and the manifest
says that rather than inventing a stronger rationale.

Call-site survey: grep finds marked in four client files, but two are the
English word in comments. The only import is RMOSCncJobDetailView.vue:135, used
once at :182. MarkdownRenderer.vue uses v-html with a hand-rolled renderer and
does not import marked at all.

Recorded in architecture_scan as one low finding, NOT repaired here:
RMOSCncJobDetailView.vue renders marked(md) into v-html at :115 with no
sanitizer. marked v5+ removed its built-in sanitize option and documents that
callers sanitize output themselves. The input is job.operator_report_md from the
CNC job detail API, so the exposure is stored rather than reflected. It predates
this bump. Adding a sanitizer dependency inside a Dependabot patch bump would
make the bump un-reviewable, so it is raised for separate triage.

Verified: CBSP21 coverage gate PASSED, architecture scan acknowledged.

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

This branch was successfully deployed

1 active deployment
Preview — 1ffe37c0 Deployed Sep 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants