Skip to content

Bug: no-reference-like-urls regex has catastrophic backtracking (ReDoS) #734

Description

@sohxxny

Environment

ESLint version: v10.8.1
@eslint/markdown version: 8.0.3
Node version: v24.14.1
npm version: v11.11.0
Operating System: darwin 25.6.0 (macOS 26.6.2)

Which language are you using?

commonmark (also reproduces under gfm)

What did you do?

no-reference-like-urls's regex has the exact same shape of vulnerability that no-reversed-media-syntax had, fixed in #693. This rule just never got the same fix. Both rules are recommended, so any project using the recommended config has this on by default.

// eslint.config.mjs
import markdown from "@eslint/markdown";

export default [
  {
    files: ["**/*.md"],
    plugins: { markdown },
    language: "markdown/commonmark",
    rules: { "markdown/no-reference-like-urls": "error" },
  },
];

Generate a single-line fixture and lint it:

node -e 'const l = "[", r = "]"; require("fs").writeFileSync("repro.md", l + "()".repeat(28) + r + "(http://example.com \"a\\\"b\")\n")'
npx eslint repro.md

This is a complete, well-formed inline link (square-bracket label, then a parenthesized URL and title) — not a malformed one. The hang happens while the rule is still trying to parse the label, before it even gets to comparing the URL against known reference identifiers.

What did you expect to happen?

ESLint finishes near-instantly and reports nothing (there's no reference definition anywhere in the file for the rule to match against, so linting this file should be a no-op for this rule regardless of file size).

What actually happened?

Wall-clock time for npx eslint repro.md, varying only the repeat count n in '()'.repeat(n):

n eslint runtime
18 0.74s
20 0.92s
22 2.64s
24 9.80s
26 37.57s
28 149.43s
30 219.33s

(Measured with time npx eslint repro.md. Absolute times will vary by machine, but the exponential growth pattern itself shouldn't be hardware-specific.)

Going from n=18 to n=30 (just 12 more repeats) pushes the runtime from under a second to 219s — exponential, not linear — and it pins one CPU core near 100% the whole time. --debug gives no indication of which rule is stuck.

Calling the rule's own regex directly (no ESLint involved) shows the same curve, confirming the regex itself is the bottleneck and not something in the rule's surrounding logic:

const linkOrImagePattern =
  /\[(?<label>(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\((?<destination>[ \t]*\r?\n?(?<![ \t])[ \t]*(?:<[^>]*>|[^ \t()]+))(?:[ \t]*\r?\n?(?<![ \t])[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\r?\n?(?<![ \t])[ \t]*\)$/u;

const l = "[", r = "]";
const testInput = l + "()".repeat(24) + r + "(http://example.com \"a\\\"b\")";
linkOrImagePattern.exec(testInput);
// ~9s just for this one exec() call

Link to Minimal Reproducible Example

N/A — the snippet above is a complete, self-contained reproduction.

Participation

  • I am willing to submit a pull request for this issue.

AI acknowledgment

  • I did not use AI to generate this issue report.
  • (If the above is not checked) I have reviewed the AI-generated content before submitting.

Additional comments

Suggested fix. Apply the same fix used in #693 to linkOrImagePattern — bound \([\s\S]*\) to \([^()]*\).

- /\[(?<label>(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\(.../u;
+ /\[(?<label>(?:\\.|[^()\\]|\([^()]*\))*?)\]\(.../u;

I ran this rule's existing test suite against the fix and all 82 tests pass.

There's a side effect here that's different in kind from #693's trade-off, though. For no-reversed-media-syntax, paren structure itself is what the rule is judging, so limiting nesting depth was a meaningful semantic restriction. This rule only cares whether the URL matches a reference definition, so nested parens in the label shouldn't matter to it at all — yet with this fix, a label with 2+ levels of nesting fails to match the regex entirely, so the destination never gets extracted and the check is silently skipped even when the URL genuinely matches a definition. For example, a link whose label is see (x (a (b)) y) and whose destination is https://example.com/doc extracts destination fine under the current regex, but fails to match at all under the bounded one.

Disclosure: I'm a participant in open source contribution program OSSCA.

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

  • Status
    Ready to Implement

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions