Bug: `no-reference-like-urls` regex has catastrophic backtracking (ReDoS)
- 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
Assessment
This issue has not been assessed yet.