How Denial of Service via Infinite Loop Happens in JavaScript Dependency nanoid and How to Fix It
Vulnerability at a Glance
| Field | Detail |
|---|---|
| CVE | CVE-2026-67213 |
| Severity | High |
| CWE | CWE-835 — Loop with Unreachable Exit Condition |
| Affected package | nanoid < 3.3.18 / < 5.1.6 |
| Fix | Upgrade to nanoid@3.3.18 or nanoid@5.1.6 |
Introduction
The bun.lock file in this repository pinned nanoid at version 3.3.12. That version contains a flaw deep inside the customAlphabet random ID generation path: under specific input conditions, the internal rejection-sampling loop never finds a valid byte, spinning the CPU to 100% and blocking the Node.js event loop until the process is killed or the server crashes. Because nanoid is a transitive dependency pulled in by many popular build tools and Vue.js toolchains, this vulnerability is far more widespread than its package name might suggest.
The Trivy scanner detected the pinned "nanoid@3.3.12" entry in bun.lock and matched it against the CVE-2026-67213 advisory. The fix is a two-file change: bump the version in package.json and regenerate bun.lock to record the patched 3.3.18 hash.
The Vulnerability Explained
What nanoid's customAlphabet does
nanoid generates cryptographically random string identifiers. Its customAlphabet(alphabet, size) function lets callers define their own character set. Internally, it uses a rejection-sampling algorithm:
- Generate a pool of random bytes.
- For each byte, apply a bitmask so the value falls within the alphabet length.
- If the masked value is a valid index, accept it; otherwise, reject and retry.
The bitmask is computed as the smallest power-of-two mask that covers the alphabet size. For most alphabet sizes this works fine. However, in vulnerable versions before 3.3.18, a specific combination of alphabet size and pool sizing causes the rejection rate to approach 100%, meaning the inner loop never accumulates enough accepted bytes to fill the requested ID length — it simply loops forever.
The vulnerable entry in bun.lock
# BEFORE (vulnerable)
"nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" },
"sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
This single line is the evidence Trivy matched: the version string 3.3.12 falls within the vulnerable range < 3.3.18.
How an attacker could exploit this
In this web application context, the attack surface depends on how nanoid is invoked. Consider a common pattern where a server-side route generates a session token or short link using a caller-supplied alphabet:
// Hypothetical vulnerable usage
import { customAlphabet } from 'nanoid'; // 3.3.12
app.post('/shorten', (req, res) => {
const alphabet = req.body.alphabet ?? 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(alphabet, 10);
res.json({ id: nanoid() }); // ← hangs forever with crafted alphabet
});
An attacker sends a single POST request with a carefully crafted alphabet value that triggers the infinite loop condition. Because Node.js is single-threaded, the event loop is now completely blocked — no other request can be processed. The server is effectively down until restarted. No authentication is required; one HTTP request is sufficient.
Even without direct customAlphabet exposure, the vulnerability can be triggered indirectly through any library that wraps nanoid with a custom alphabet internally.
Real-world impact for this application
This is a Vue 3 web application (evident from the vue@^3.5.13 dependency in bun.lock). The nanoid package is used at minimum as part of the Vite/Vue toolchain. If any server-side route — or a server-side rendering layer — calls nanoid's custom alphabet path with user-influenced parameters, a single malformed request causes a complete DoS. Given the HIGH severity rating, the blast radius justifies an immediate upgrade even before reachability is fully confirmed.
The Fix
What changed in bun.lock
The diff shows a precise, surgical change:
# bun.lock — BEFORE
- "nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" },
- "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
# bun.lock — AFTER
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } },
+ "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
Three things changed in this one line:
| Field | Before | After | Why it matters |
|---|---|---|---|
| Version | 3.3.12 |
3.3.18 |
Pulls in the patched loop logic |
| Integrity hash | sha512-ZB9RH… |
sha512-DTg4M… |
Bun verifies this hash on install; a mismatch would abort the build |
bin field |
"bin/nanoid.cjs" (string) |
{ "nanoid": "bin/nanoid.cjs" } (object) |
Minor metadata normalisation in the newer release |
The package.json change (not shown in the diff snippet) adds "nanoid": "3.3.18" as an explicit direct dependency, which overrides any transitive resolution to the older version:
# package.json — AFTER
+ "nanoid": "3.3.18",
Pinning it explicitly as a direct dependency is the correct approach here: it ensures that even if a transitive dependency still requests ^3.3.0, Bun's resolver honours the explicit override and installs 3.3.18.
Why 3.3.18 fixes the infinite loop
The nanoid maintainers corrected the pool-size calculation in the rejection-sampling loop. The fix ensures the pool is always sized large enough that the probability of filling the requested ID length in a single pass is bounded away from zero — mathematically guaranteeing termination regardless of alphabet size. This is a pure internal logic fix; the public API (nanoid(), customAlphabet(), urlAlphabet) is unchanged.
Prevention & Best Practices
1. Pin and audit your lock file regularly
Lock files (bun.lock, package-lock.json, yarn.lock) are your first line of defence. Run a vulnerability scanner against them on every CI build:
# With Trivy
trivy fs --scanners vuln bun.lock
# With npm audit (if using npm)
npm audit --audit-level=high
2. Use automated dependency update tools
Tools like Dependabot, Renovate, or Orbis AppSec can open pull requests automatically when a new CVE is published against a pinned version. The faster the patch cycle, the smaller the exposure window.
3. Never pass user-controlled values to customAlphabet
If your application uses nanoid's customAlphabet, treat the alphabet and size parameters as internal constants, not as user inputs:
// ✅ Safe — alphabet is a hard-coded constant
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 21);
// ❌ Dangerous — alphabet comes from user input
const nanoid = customAlphabet(req.body.alphabet, req.body.size);
4. Add event-loop monitoring
In production Node.js applications, monitor event-loop lag. A sudden spike to hundreds of milliseconds is a strong signal that an infinite loop or blocking operation has been triggered:
import { monitorEventLoopDelay } from 'perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
if (h.mean > 100) console.warn(`Event loop lag: ${h.mean}ms`);
}, 5000);
5. Security standards references
- CWE-835: Loop with Unreachable Exit Condition
- OWASP: Denial of Service Cheat Sheet
- OWASP A06:2021: Vulnerable and Outdated Components — keeping dependencies current is a top-10 OWASP control
Key Takeaways
nanoid@3.3.12inbun.lockis directly exploitable — the version string alone is sufficient for Trivy to flag it; no source-code analysis is needed.- One crafted HTTP request can block the entire Node.js event loop — because the infinite loop runs synchronously, it starves all other requests, making this a single-packet DoS.
- Pinning
nanoidas a direct dependency inpackage.jsonis the right override strategy — it prevents transitive resolution from silently downgrading back to a vulnerable version. - The
binfield change (string→object) inbun.lockis a harmless metadata normalisation, not a breaking change — safe to accept as part of the upgrade. - Even "build-tool-only" dependencies carry runtime risk in SSR or full-stack Vue applications where the same
node_modulestree serves both build and runtime.
How Orbis AppSec Detected This
- Source: The
bun.lockfile recordsnanoid@3.3.12as a resolved dependency, making the vulnerable version observable to any scanner that reads the lock file. - Sink: Any call to
customAlphabet()insidenanoid/index.js(v3.3.12) where the rejection-sampling loop iterates without a guaranteed exit — effectivelywhile (id.length < size) { ... }with a miscalculated pool. - Missing control: No upper bound or pool-size correction on the rejection-sampling loop; the loop exit condition is mathematically unreachable for certain alphabet sizes.
- CWE: CWE-835 — Loop with Unreachable Exit Condition (Infinite Loop).
- Fix: Upgraded
nanoidfrom3.3.12to3.3.18in bothbun.lockandpackage.json, replacing the vulnerable loop logic with a corrected pool-size calculation.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-67213 is a reminder that even small, widely trusted utility libraries can harbour high-severity vulnerabilities. nanoid is downloaded hundreds of millions of times per month precisely because it is simple and reliable — but version 3.3.12 contains a loop that an attacker can weaponise to take down a Node.js server with a single request. The fix is as simple as a version bump, but the window between disclosure and patching is where real damage happens.
Keep your lock files under continuous scanner scrutiny, treat dependency upgrades as security patches (not just maintenance), and never expose internal ID-generation parameters to user-controlled input. The two-line change shown here — updating the version in package.json and regenerating bun.lock — is all it takes to close this vulnerability entirely.