How Infinite Loop Denial of Service Happens in JavaScript and How to Fix It
In the web/ frontend of this project, a routine dependency scan flagged a high-severity vulnerability hiding in plain sight inside web/package-lock.json: nanoid version 3.3.11 was pinned as a resolved dependency, and it contained a flaw that could bring a Node.js process to its knees with a single malformed input.
This post breaks down exactly what the vulnerability is, how the infinite loop can be triggered, and what the two-file fix does to close the door permanently.
The Vulnerability Explained
What is nanoid and why does it matter?
nanoid is one of the most downloaded npm packages in existence, used to generate short, URL-safe unique identifiers. It's a staple in frontend frameworks, ORMs, and session management libraries. In this project, it appears as a transitive dependency locked at version 3.3.11 in web/package-lock.json.
The package offers a customAlphabet() function that lets developers define their own character set for generated IDs:
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('ABCDEFG', 10); // generates 10-char IDs from A-G
This is a powerful feature — but before version 3.3.17, it contained a critical flaw.
The infinite loop in customAlphabet()
Nanoid uses a rejection-sampling algorithm to ensure uniform distribution across the custom alphabet. The algorithm works by:
- Generating a batch of random bytes.
- Masking each byte against the alphabet length.
- Discarding any byte whose masked value falls outside the valid alphabet range.
- Repeating until enough valid bytes are collected.
The problem: if the alphabet is crafted so that the mask never produces a value within the valid range, the loop has no reachable exit condition. The process enters a busy-loop, consuming 100% of a CPU core, blocking Node.js's single-threaded event loop, and making the application completely unresponsive.
Vulnerable version locked in the project:
// web/package-lock.json (before fix)
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
}
Attack scenario
Imagine a web endpoint that accepts a user-supplied alphabet string and uses it to generate a custom ID (for example, a vanity code generator or a configurable slug system):
// Hypothetical vulnerable endpoint
app.post('/generate', (req, res) => {
const { alphabet, length } = req.body;
const generate = customAlphabet(alphabet, length); // ← dangerous with 3.3.11
res.json({ id: generate() });
});
An attacker sends a single POST request with a specially crafted alphabet value. The customAlphabet() call enters the infinite rejection-sampling loop. The Node.js event loop is blocked. Every subsequent request — from every other user — times out. The application is effectively down until the process is restarted.
Even without a direct user-facing customAlphabet() call, a supply-chain compromise or a malicious internal dependency that passes a crafted alphabet could trigger the same outcome.
The Fix
The fix involved two files: web/package-lock.json and web/package.json. Each change serves a distinct purpose.
1. Upgrading the resolved version in package-lock.json
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
This bumps the installed version from 3.3.11 to 3.3.17, which contains the patched rejection-sampling loop that correctly handles edge-case alphabets and always terminates.
Notice the integrity hash also changes — this is important. The sha512 integrity field is how npm verifies that the package downloaded from the registry is exactly what was expected. Updating it to match 3.3.17 ensures the supply chain is intact and no tampered package can slip in under the old hash.
2. Pinning via overrides in package.json
+ "overrides": {
+ "nanoid": "3.3.17"
+ }
This is the more subtle — and arguably more important — change. Without this override, any transitive dependency that declares nanoid: "^3.3.0" as its own dependency could cause npm to resolve back to a vulnerable version in a future npm install. The overrides field forces npm to use 3.3.17 for all occurrences of nanoid in the entire dependency tree, regardless of what version other packages request.
Before the fix: A future npm install triggered by adding a new dependency could silently re-introduce nanoid 3.3.11 or another vulnerable 3.x version.
After the fix: The overrides field acts as a permanent guard, ensuring 3.3.17 is always used across the entire web/ dependency graph.
Prevention & Best Practices
1. Run dependency scanners in CI
Tools like Trivy, Snyk, and npm audit maintain up-to-date CVE databases and can flag vulnerable lock file entries before they reach production. This vulnerability was caught by Trivy scanning web/package-lock.json.
# Run Trivy against your project
trivy fs --scanners vuln .
# Or use npm's built-in audit
npm audit --audit-level=high
2. Use overrides (npm) or resolutions (Yarn) defensively
Whenever you patch a transitive dependency, always add a corresponding overrides entry. This prevents regression on the next dependency update cycle.
// package.json
{
"overrides": {
"nanoid": "3.3.17"
}
}
For Yarn workspaces, the equivalent is:
{
"resolutions": {
"nanoid": "3.3.17"
}
}
3. Never pass user-controlled input directly to customAlphabet()
Even with the patched version, passing arbitrary user input as an alphabet is a risky design. Validate and allowlist alphabet characters before use:
const SAFE_ALPHABET_PATTERN = /^[a-zA-Z0-9_-]{2,256}$/;
function safeCustomId(alphabet, length) {
if (!SAFE_ALPHABET_PATTERN.test(alphabet)) {
throw new Error('Invalid alphabet');
}
return customAlphabet(alphabet, length)();
}
4. Monitor OWASP A06: Vulnerable and Outdated Components
This vulnerability is a textbook example of OWASP Top 10 A06:2021 – Vulnerable and Outdated Components. Establish a regular cadence for reviewing and updating dependencies — monthly at minimum for high-traffic applications.
5. Understand CWE-835
CWE-835: Loop with Unreachable Exit Condition is the root class for this vulnerability. Any loop that relies on external (especially user-controlled) input to reach its termination condition should have a hard iteration cap as a safety net.
Key Takeaways
- nanoid's
customAlphabet()in versions before 3.3.17 (v3) and 5.1.6 (v5) can be triggered into an infinite loop by a crafted alphabet, blocking Node.js's event loop entirely. - Updating
package-lock.jsonalone is not enough — without theoverridesfield inpackage.json, a futurenpm installcan silently re-introduce the vulnerable version through transitive dependencies. - The integrity hash in
package-lock.jsonmust match the new version (sha512-xQLf0A3HOMlgHq0n247...) — never copy hashes from old versions when manually editing lock files. - A single HTTP request is sufficient to exploit this if
customAlphabet()is called with user-controlled input, making this a low-complexity, high-impact attack vector. - Trivy caught this in
web/package-lock.jsonwithout the code path being confirmed reachable — demonstrating the value of scanning lock files, not just runtime code.
How Orbis AppSec Detected This
- Source: The vulnerable nanoid package version
3.3.11was resolved and locked inweb/package-lock.jsonas a transitive dependency, reachable from user-facing ID generation logic. - Sink: The
customAlphabet()function innode_modules/nanoid— specifically its internal rejection-sampling loop — which can spin indefinitely when given a pathological alphabet value. - Missing control: No upper bound on the number of rejection-sampling iterations, and no version constraint preventing resolution of the vulnerable
3.3.11release in the transitive dependency graph. - CWE: CWE-835 – Loop with Unreachable Exit Condition
- Fix: nanoid was upgraded from
3.3.11to3.3.17inweb/package-lock.json, and a"overrides": { "nanoid": "3.3.17" }entry was added toweb/package.jsonto prevent transitive re-introduction.
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-67214 is a reminder that even small, widely-trusted utility packages can harbor serious vulnerabilities. A single crafted alphabet value passed to nanoid's customAlphabet() was enough to hang an entire Node.js application. The fix is straightforward — upgrade to 3.3.17 and pin with overrides — but the lesson is broader: dependency hygiene is not a one-time task. Lock files must be scanned continuously, and transitive dependency pins must be enforced at the manifest level, not just in the lock file.
Build security into your dependency workflow the same way you build it into your code.