How Prototype Pollution Happens in Node.js async Libraries and How to Fix It
The Incident
In the node-red-contrib-opcua project — a Node-RED plugin for OPC-UA industrial automation — the Trivy scanner flagged a high-severity vulnerability in a seemingly innocuous dependency: the async npm package pinned at version 3.2.1 inside package-lock.json. The vulnerability, CVE-2021-43138, is a classic Prototype Pollution flaw that could allow an attacker to silently corrupt JavaScript's global object prototype, potentially turning a routine async iteration call into a remote code execution vector.
This post breaks down exactly what went wrong, how the attack works in the context of this application, and what the fix does at the code level.
The Vulnerability Explained
What Is Prototype Pollution?
Every JavaScript object inherits properties from Object.prototype. If an attacker can inject a property into Object.prototype — say, isAdmin: true — then every object in the running Node.js process suddenly has isAdmin set to true, unless it explicitly overrides it. This is prototype pollution.
The attack vector is deceptively simple: pass an object with a key like __proto__ or constructor to a function that naively merges or iterates over object keys without filtering them.
The Vulnerable Code Pattern in async 3.2.1
The async library (version 3.2.1, pinned in package-lock.json) contained internal object-manipulation logic in several of its utility functions — including mapValues, reduce, and related iterators — that did not sanitize incoming keys. A simplified representation of the vulnerable pattern looks like this:
// Vulnerable pattern inside async 3.2.1 (simplified)
function mapValues(obj, iteratee, callback) {
var keys = Object.keys(obj); // does NOT filter __proto__
var result = {};
each(keys, function(key, next) {
iteratee(obj[key], key, function(err, value) {
result[key] = value; // assigns to result[key] — including __proto__
next(err);
});
}, function(err) {
callback(err, result);
});
}
The critical flaw: Object.keys() in older V8 environments and certain edge cases — combined with how async assigned back to result[key] — could be exploited to set result["__proto__"]["polluted"] = true, which propagates to Object.prototype.
The vulnerable version was locked in package-lock.json with this exact entry:
"node_modules/async": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz",
"integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==",
"license": "MIT"
}
How This Could Be Exploited in node-red-contrib-opcua
node-red-contrib-opcua is an industrial IoT plugin. It processes OPC-UA messages — structured data objects that can arrive from external devices or over a network. If any part of the message-handling pipeline passes an attacker-controlled object (e.g., a crafted OPC-UA node configuration or a malicious Node-RED flow payload) through one of async's iteration functions, the attacker could inject:
// Attacker-crafted input object
{
"__proto__": {
"admin": true,
"isAuthenticated": true
}
}
Once Object.prototype is polluted, any subsequent code that checks obj.admin or obj.isAuthenticated without explicit prototype checks would return true — even for freshly created objects. In a Node-RED environment where flows dynamically evaluate node properties, this could bypass authorization logic or alter control flow in dangerous ways.
Real-world impact for this application:
- Bypass of permission checks in Node-RED's flow execution engine
- Corruption of OPC-UA session state objects
- Potential for denial-of-service if polluted properties break internal object assumptions
- In worst-case scenarios involving eval or dynamic property access downstream: remote code execution
The Fix
What Changed
The fix is a targeted dependency upgrade across two files: package.json and package-lock.json.
package.json — Before:
"async": "3.2.1",
package.json — After:
"async": "^3.2.2",
The change from an exact pin (3.2.1) to a caret range (^3.2.2) is deliberate: it allows npm to automatically pick up future patch releases within the 3.x minor line, reducing the window of exposure for similar future vulnerabilities.
package-lock.json — Before:
"node_modules/async": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz",
"integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==",
"license": "MIT"
}
package-lock.json — After:
"node_modules/async": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.2.tgz",
"integrity": "sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g==",
"license": "MIT"
}
The integrity hash change is critical: it cryptographically pins the patched tarball. Any attempt to install the old vulnerable version would fail the integrity check, providing a hard guarantee that the vulnerable code is gone.
Why Two Files?
package.jsondefines the intent — what version range the project wants.package-lock.jsondefines the reality — the exact resolved version and its verified hash.
Updating only package.json would leave the lockfile pointing to 3.2.1 in environments that respect lockfiles strictly (like most CI/CD pipelines using npm ci). Both files must be updated to guarantee the patched version is actually installed everywhere.
What async 3.2.2 Changed Internally
The patch in async 3.2.2 added explicit key filtering to prevent __proto__, constructor, and prototype from being treated as regular object keys during merge and iteration operations. The fix follows the same pattern recommended by the Node.js security team:
// Patched approach (conceptual — async 3.2.2)
function isSafeKey(key) {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
}
// Keys are now filtered before assignment
keys.filter(isSafeKey).forEach(function(key) {
result[key] = transformedValue;
});
This ensures that even if an attacker passes a crafted object with __proto__ as a key, it is silently dropped rather than merged into the result object.
Prevention & Best Practices
1. Audit Your Dependency Tree Regularly
Prototype pollution vulnerabilities are almost always introduced through transitive dependencies. Run:
npm audit
# or with more detail:
npx better-npm-audit audit
Add this to your CI pipeline so new vulnerabilities are caught before they reach production.
2. Use npm ci in Production
npm ci installs exactly what is in package-lock.json and fails if there's a mismatch. This prevents silent upgrades to vulnerable versions and ensures the integrity hashes are verified.
# In your CI/CD pipeline:
npm ci --production
3. Freeze Dependency Ranges Carefully
Exact pins ("async": "3.2.1") feel safe but prevent automatic security patches. Caret ranges ("async": "^3.2.2") allow patch and minor updates — a better balance for security-sensitive dependencies. For critical libraries, consider using npm audit fix in CI to auto-apply security patches.
4. Sanitize Object Keys When Merging User Input
If your own code merges external objects, always filter dangerous keys:
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (!UNSAFE_KEYS.has(key)) {
target[key] = source[key];
}
}
return target;
}
Alternatively, use Object.create(null) to create prototype-free objects when handling untrusted data:
const safeObj = Object.create(null);
// This object has NO prototype — pollution is impossible
5. Use a Software Composition Analysis (SCA) Tool
Tools like Trivy (which caught this vulnerability), Snyk, or GitHub Dependabot continuously monitor your dependency tree against known CVE databases. Integrate them into your pull request workflow.
Security Standards
- OWASP A06:2021 – Vulnerable and Outdated Components: This vulnerability is a textbook example of why keeping dependencies updated is a security requirement, not just a maintenance chore.
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes — the formal classification for prototype pollution.
Key Takeaways
- Pinning
asyncto3.2.1inpackage-lock.jsonwas the direct cause: the exact integrity hash locked in the vulnerable tarball, preventing automatic security updates from taking effect. - The
__proto__key is the classic prototype pollution vector: any library that iterates or merges object keys without filtering it is potentially vulnerable — always check changelogs when upgrading utility libraries. - Both
package.jsonandpackage-lock.jsonmust be updated: changing only one file leaves the vulnerability intact in strict-lockfile environments likenpm ci. - Industrial IoT contexts amplify the risk:
node-red-contrib-opcuaprocesses external device data, meaning attacker-controlled objects can realistically reachasync's iteration functions through crafted OPC-UA payloads. - Caret ranges (
^3.2.2) over exact pins for security-sensitive packages: the updatedpackage.jsonnow allows automatic uptake of future3.xsecurity patches without requiring a manual PR for every fix.
How Orbis AppSec Detected This
- Source: External OPC-UA message payloads and Node-RED flow configurations processed by
node-red-contrib-opcua, which can contain attacker-controlled object keys. - Sink:
async3.2.1's internal object iteration and merge functions (e.g.,mapValues,reduce) innode_modules/async, called with user-influenced data objects — flagged via thenode_modules/asyncentry inpackage-lock.json. - Missing control: The
async3.2.1 library did not filter__proto__,constructor, orprototypekeys before performing object property assignments, and the project had no sanitization layer before passing external data to async utility functions. - CWE: CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').
- Fix: Upgraded
asyncfrom the exact pin3.2.1to^3.2.2inpackage.jsonand regeneratedpackage-lock.jsonwith the patched version's cryptographic integrity hash.
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-2021-43138 is a reminder that prototype pollution doesn't require complex exploit chains — a single unfiltered object key passed to a widely-used utility library is enough. In the node-red-contrib-opcua project, the vulnerable async 3.2.1 package sat quietly in package-lock.json, locked to a specific tarball hash that guaranteed the vulnerable code was always installed.
The fix is surgical and low-risk: upgrading to async ^3.2.2 closes the vulnerability while the caret range ensures future security patches are picked up automatically. For developers building Node.js applications — especially those processing external or industrial data — regularly auditing your dependency tree and integrating SCA tools into CI is not optional. The attack surface of your application includes every line of code in node_modules.