CI Report:
https://ci-tests.linuxserver.io/linuxserver/thelounge/nightly-0d3952d3-ls220/index.html
LinuxServer Changes:
Full Changelog: nightly-438525ff-ls219...nightly-0d3952d3-ls220
Remote Changes:
Update dependency linkify-it to v5 [SECURITY] (#5116)
This PR contains the following updates:
| Package | Change |
Age |
Confidence |
|---|---|---|---|
| linkify-it |
3.0.3 →
5.0.2 |
|
|
LinkifyIt#match scan loop has quadratic algorithmic complexity
CVE-2026-48801 / 64 KB of The same cost passes through The loop iterates O(N) times (once per match). Each iteration:
Total cost: The schema-prefixed scan in the same That branch uses a Doubling N → ~4× wall-clock, consistent with O(N²).
Same quadratic curve. 64 KB is enough to burn 2.6 s in Real-world scenario: Any service that renders untrusted Markdown The fix is algorithmic — convert the outer scan loop to stateful regex The schema branch at Total cost becomes O(N): each character scanned at most once per regex This finding appears novel.
Since https://github.com/advisories/GHSA-22p9-wv53-3rq4
This data is provided by the GitHub Advisory
GHSA-22p9-wv53-3rq4
More information
Details
Summary
LinkifyIt.prototype.match — the package's primary public API — has
O(N²) algorithmic complexity for inputs containing many fuzzy links
or emails. This is not a regex backtrack bug; it's a structural issue in
the JS-level scan loop that re-slices the input and re-runs unanchored
regex searches on progressively shorter tails, N times.
"a@b.com\n" repeated burns ~2.5 s of single-threaded CPU; 128
KB takes ~10 s. Doubling the input quadruples the time — textbook O(N²).
markdown-it (linkify:true) unmodified.
Any service that synchronously renders untrusted Markdown with linkify
enabled on a request hot-path (forums, comments, chat, wikis, AI chat
UIs) inherits a worker-process DoS triggerable by a tens-of-KB request
body.
Affected component
8e887d5bace3f5b09b1d1f70492fa0364ef1793d (v5.0.0)
LinkifyIt.prototype.match — index.mjs:528-554
test(): index.mjs:444 (fuzzy host
search), :448 (fuzzy link match), :467 (fuzzy email match)
markdown-it (~21.6M weekly npm DLs) calls
linkify.match() at lib/rules_core/linkify.mjs:57 when linkify:true
initial commit (2014) through v5.0.0
Vulnerability details
The O(N²) outer loop
index.mjs:528-554:
LinkifyIt.prototype.match = function match (text) {
const result = []
let shift = 0
let tail = shift ? text.slice(shift) : text
while (this.test(tail)) {
result.push(createMatch(this, shift))
tail = tail.slice(this.__last_index__) // <-- re-allocates remaining tail each iteration
shift += this.__last_index__
}
if (result.length) return result
return null
}
tail.slice() re-allocates a string of length |text| - shift —
O(N) per iteration
this.test(tail) runs three unanchored regex searches over the full
new tail:
// index.mjs:444 — full-tail search
tld_pos = text.search(this.re.host_fuzzy_test)
// index.mjs:448 — full-tail match
ml = text.match(this.re.link_fuzzy)
// index.mjs:467 — full-tail match
me = text.match(this.re.email_fuzzy)
Σ(N - i*c) for i=0..N = O(N²).
Contrast with the linear schema branch
test() function does it correctly
at index.mjs:428-440:
re = this.re.schema_search
re.lastIndex = 0
while ((m = re.exec(text)) !== null) { ... }
g-flag RegExp and advances lastIndex — linear.
The fuzzy branches don't follow this pattern.
Proof of concept
mkdir /tmp/linkifyit-redos && cd /tmp/linkifyit-redos
npm install linkify-it@5.0.0
cat > poc.mjs <<'EOF'
import LinkifyIt from 'linkify-it'
const l = new LinkifyIt()
for (const n of [1000, 2000, 4000, 8000, 16000]) {
const evil = 'a@b.com\n'.repeat(n)
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`)
}
EOF
node poc.mjs
Measured output (Node v25.5.0, 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
markdown-it transitive (independently confirmed)
npm install markdown-it@14.1.1
node -e "
const md = require('markdown-it')({ linkify: true })
for (const n of [1000, 2000, 4000, 8000]) {
const evil = 'a@b.com '.repeat(n)
const t0 = process.hrtime.bigint()
md.render(evil)
const ms = Number(process.hrtime.bigint() - t0) / 1e6
console.log('n=' + n + ' bytes=' + evil.length + ' md.render=' + ms.toFixed(0) + 'ms')
}
"
n=1000 bytes=8000 md.render=45ms
n=2000 bytes=16000 md.render=171ms
n=4000 bytes=32000 md.render=672ms
n=8000 bytes=64000 md.render=2636ms
markdown-it.render().
Impact
of repeated email-like strings blocks one worker thread for seconds to
tens of seconds. Under moderate concurrency (10-50 requests), the entire
rendering tier of an affected service is wedged.
with linkify:true on the request path — Discourse, Mattermost, GitLab
CE, AI chat UIs (Open WebUI, LibreChat), wiki/note apps using
markdown-it — receives a post/comment containing 64 KB of "a@b.com ".
The render call blocks the worker for 2.5+ seconds. Scripted at scale,
this wedges the rendering tier.
Suggested remediation
iteration so each character is examined a constant number of times:
g flag to email_fuzzy, link_fuzzy, link_no_ip_fuzzy,
host_fuzzy_test in lib/re.mjs
test() (or add testAt(text, pos)) so fuzzy branches set
re.lastIndex = pos and call re.exec(text) instead of
text.match()/text.search() on a sliced tail
match(), drop tail = tail.slice(...) entirely — advance a
pos offset instead
index.mjs:428-440 is already structured this way
— it's the in-repo precedent for the fix.
// proposed sketch
LinkifyIt.prototype.match = function match (text) {
const result = []
let pos = 0
while (this.testAt(text, pos)) {
result.push(createMatch(this, 0))
pos = this.__last_index__
}
return result.length ? result : null
}
across the whole loop.
Duplicate-risk analysis
linkify-it (gh api /repos/markdown-it/linkify-it/security-advisories → [])
api.osv.dev/v1/query → {})
rule") targets markdown-it's own newline regex, not the linkify pipeline
Note to maintainers
markdown-it is the dominant consumer and shares maintainership
(Vitaly Puzrin), a patched linkify-it release should be paired with a
markdown-it minor that pins the new minimum version.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
References
https://github.com/markdown-it/linkify-it/security/advisories/GHSA-22p9-wv53-3rq4
Database
(CC-BY
4.0).
linkify-it: Quadratic-complexity DoS via the mailto: validator
scan-loop on attacker text The ×~4 per 2× input ⇒ O(n²); equal-byte controls stay flat ⇒ algorithmic, Reachable on arbitrary user text via the documented Bound the email local-part per RFC 5321 (≤64) so per-hit work is O(1), All versions through 5.0.1 (latest); same code on https://github.com/advisories/GHSA-v245-v573-v5vm
This data is provided by the GitHub Advisory
CVE-2026-59887 /
GHSA-v245-v573-v5vm
More information
Details
Summary
linkify-it's schema-scan loop (.test() / .match(), the documented
public API) invokes the mailto:
schema validator at every mailto: occurrence in the input text.
For each occurrence the validator does
text.slice(pos) (an O(n) copy) and runs an email regex whose
local-part class src_email_name greedily
scans the entire remaining tail (O(n)) before failing. With N
mailto: occurrences that is
N × O(n) = O(n²). Because linkify-it runs on arbitrary user text
(markdown-it feeds it whole documents
when linkify:true), an unauthenticated attacker can block the
single-threaded event loop for many seconds
with a small input. No length bound (unlike an HTTP header).
Root cause —
index.mjs + lib/re.mjs// index.mjs (mailto validator) — runs at every "mailto:" hit
'mailto:': { validate: function (text, pos, self) {
const tail = text.slice(pos) // O(n) copy per hit
if (!self.re.mailto) self.re.mailto = new RegExp('^' + self.re.src_email_name + '@​' + self.re.src_host_strict, 'i')
if (self.re.mailto.test(tail)) { ... } // scans the whole O(n) tail
return 0
}}
// lib/re.mjs:91-93 — every char of "mailto:" (incl. ':','-',';') is in this class:
re.src_email_name = '[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*'
while ((m = re.exec(text)) !== null) { …testSchemaAt… } scan loop
calls the validator at each
mailto: hit; src_email_name greedily consumes the whole tail (all
chars are in its class) then fails for
lack of @. http:/https: do NOT blow up — their validator requires
the tail to start with //, failing
in O(1) per hit.
Proof of Concept (confirmed, linkify-it 5.0.1, Node v24)
const LinkifyIt = require('linkify-it');
const lf = new LinkifyIt();
lf.match('mailto:'.repeat(48000)); // ~336 KB of "mailto:mailto:…" -> seconds of blocked event loop
input (same bytes)
56 KB
112 KB
224 KB
336 KB
mailto: contiguous
97 ms
357 ms
1438 ms
3272 ms
mailto: space-separated
2 ms
3 ms
5 ms
8 ms
http:// contiguous
12 ms
17 ms
33 ms
49 ms
not a GC/allocation artifact.
Real-world via markdown-it 14.x ({linkify:true}),
md.render('mailto:'.repeat(n)): 219 KB ≈ ~5 s.
Impact
.test()/.match()
API and through markdown-it's
linkifier — comment systems, chat, forums, wikis, note apps that render
user markdown with linkify enabled.
A ~220 KB post hangs the event loop ~5 s; a few hundred KB → tens of
seconds. Availability only.
Suggested remediation
and avoid the full-tail slice:
// lib/re.mjs — cap the greedy run:
re.src_email_name = '[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]{0,63}'
// index.mjs — prefer a sticky regex anchored at `pos` over text.slice(pos).
Affected / disclosure
master. cve-mcp/OSV
report no known vulnerability for
linkify-it. Distinct from markdown-it's own *-run ReDoS
(CVE-2026-2327, different package/path) and the
recent markdown-it DoS. Reported privately; happy to test a patch
against the PoC.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
References
https://github.com/markdown-it/linkify-it/security/advisories/GHSA-v245-v573-v5vm
https://nvd.nist.gov/vuln/detail/CVE-2026-59887
https://github.com/markdown-it/linkify-it/commit/105e5d77f7d119871d2b2d86ed208568eb3e7ffe
https://github.com/markdown-it/linkify-it/releases/tag/5.0.2
Database
(CC-BY
4.0).
Release Notes
markdown-it/linkify-it (linkify-it)
- Fixed DoS in
mailto:links (restrict user name to 64 chars). - Restricted user/pass part length in links.
- Fixed DoS in fuzzy links/emails search.
- Reworked search logic - check each pattern separate, use
gregexes
instead
of slice. - Removed internal cache - useless overcomplication.
- Rewrite to ESM.
- Fix
http://incorrectly returned as a link by matchStart.
- Add
matchAtStartmethod to match full URLs at the start of the
string. - Fixed paired symbols (
(),{},"", etc.) after punctuation. ---option now affects parsing of emails (e.g.
user@example.com---)
Configuration
📅 Schedule: (UTC)
- Branch creation
- At any time (no schedule defined)
- Automerge
- At any time (no schedule defined)
🚦 Automerge: Disabled by config. Please merge this manually once you
are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update
again.
- If you want to rebase/retry this PR, check
this box
This PR was generated by Mend Renovate.
View the repository job
log.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>