eslint / eslint/markdown

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

Open
#734 2 comments 0 reactions 1 assignee Claimed by @sohxxny View on GitHub
accepted bug repro:yes
Dominant language
JavaScript
Stars
581
Forks
92
Avg merge
1d 4h
Merged PRs (30d)
11

Description

### 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.

```js
// 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:

```sh
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:

```js
const linkOrImagePattern =
/\[(?(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\((?[ \t]*\r?\n?(?]*>|[^ \t()]+))(?:[ \t]*\r?\n?(?(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\(.../u;
+ /\[(?(?:\\.|[^()\\]|\([^()]*\))*?)\]\(.../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](https://github.com/eslint-ossca).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.