How Prototype Pollution Happens in Node.js and How to Fix It
The Problem Hidden in Your Lock File
The yarn.lock file is easy to overlook — it's auto-generated, rarely read by humans, and usually committed without much scrutiny. But it encodes the exact versions of every dependency, including transitive ones you never explicitly chose. In this project, yarn.lock was carrying two separate entries for postcss: a modern 8.5.8 build and a legacy 6.0.23 build, the latter pulling in a version of chalk and supports-color that depended on a vulnerable version of lodash.
That lodash version contained CVE-2020-8203, a high-severity prototype pollution flaw in the zipObjectDeep function. The fix — upgrading postcss to 8.5.12 and removing the stale v6 entry — is surgical and precise, but understanding why it was necessary requires a look at what prototype pollution actually does at runtime.
The Vulnerability Explained
What Is Prototype Pollution?
In JavaScript, every object inherits from Object.prototype. If an attacker can control the key used in a deep-assignment operation, they can write to __proto__ and inject properties onto the shared prototype. Because all objects share this prototype, the injected property becomes visible on every plain object in the Node.js process — not just the one being manipulated.
Lodash's zipObjectDeep function takes two arrays — one of paths, one of values — and builds a deeply nested object:
// Vulnerable usage pattern in lodash < 4.17.19
_.zipObjectDeep(['a.b.c'], [1]);
// Returns: { a: { b: { c: 1 } } }
// Attacker-controlled input:
_.zipObjectDeep(['__proto__.polluted'], [true]);
// Now: ({}).polluted === true — on EVERY object in the process
The zipObjectDeep function did not validate whether path segments contained __proto__ or constructor before performing the deep assignment. This allowed a crafted path string to escape the intended object and write directly to the global prototype chain.
How This Reached the Project
The vulnerable lodash version entered the dependency tree through postcss 6.0.23, which was still present in yarn.lock alongside the newer postcss 8.x:
# Before the fix — yarn.lock contained TWO postcss entries:
"postcss@npm:^6.0.1, postcss@npm:^6.0.2":
version: 6.0.23
resolution: "postcss@npm:6.0.23"
dependencies:
chalk: "npm:^2.4.1" # <-- pulls supports-color, which pulls lodash
source-map: "npm:^0.6.1"
supports-color: "npm:^5.4.0"
"postcss@npm:^8.4.40":
version: 8.5.8
resolution: "postcss@npm:8.5.8"
dependencies:
nanoid: "npm:^3.3.11"
picocolors: "npm:^1.1.1"
source-map-js: "npm:^1.2.1"
The chalk@npm:^2.4.1 dependency in the postcss 6.x entry was the gateway: that version of chalk depended on supports-color@^5.4.0, which in turn carried the vulnerable lodash. The postcss 8.x entry had already modernized its dependency chain (using picocolors instead of chalk), but the stale v6 entry kept the old chain alive.
Real-World Impact for This Application
In the context of a web application using postcss to process CSS (for example, in a webpack build pipeline or a server-side CSS-in-JS renderer), an attacker who can influence CSS input could potentially:
- Trigger denial of service by crafting CSS that causes postcss's parsing logic to invoke the vulnerable lodash path with attacker-controlled keys, polluting
Object.prototypeand breaking assumptions in other parts of the application. - Inject properties into the prototype chain, causing security checks that rely on property existence (e.g.,
if (obj.isAdmin)) to return unexpected truthy values. - Exploit downstream logic — once
Object.prototypeis polluted, any code path that reads properties from plain objects without explicithasOwnPropertyguards becomes a potential vector.
The scanner assessment notes the vulnerability is "present in dependency tree, not confirmed reachable," but the presence of the vulnerable code in the resolved dependency graph is sufficient risk to warrant immediate remediation.
The Fix
Two Files, Three Changes
The fix touches package.json and yarn.lock. Here's exactly what changed and why each change was necessary.
1. package.json — Pinning via Yarn Resolutions
// Before
{
"packageManager": "yarn@4.17.1"
}
// After
{
"packageManager": "yarn@4.17.1",
"resolutions": {
"postcss": "8.5.12"
}
}
The resolutions field is a Yarn-specific mechanism that forces all packages in the dependency tree — regardless of what version they request — to resolve to the specified version. Without this, even if you upgrade your direct postcss dependency, a transitive dependency that requests postcss@^6.0.1 would still resolve to 6.x. The resolution override breaks that pattern entirely.
2. yarn.lock — Removing the postcss 6.x Entry
-"postcss@npm:^6.0.1, postcss@npm:^6.0.2":
- version: 6.0.23
- resolution: "postcss@npm:6.0.23"
- dependencies:
- chalk: "npm:^2.4.1"
- source-map: "npm:^0.6.1"
- supports-color: "npm:^5.4.0"
- checksum: 10/218e21b4f42b2147b03c1a8898271629c9fd5b53a08...
- languageName: node
- linkType: hard
-"postcss@npm:^8.4.40":
- version: 8.5.8
- resolution: "postcss@npm:8.5.8"
+ "postcss@npm:8.5.12":
+ version: 8.5.12
+ resolution: "postcss@npm:8.5.12"
dependencies:
nanoid: "npm:^3.3.11"
picocolors: "npm:^1.1.1"
source-map-js: "npm:^1.2.1"
- checksum: 10/cbacbfd7f767e2c820d4bf09a3a744834dd7d14f69ff...
+ checksum: 10/ec6b79b68c363eca3c8ffceb134a4ab637274aee6ac...
The lock file now contains a single postcss entry, pinned to 8.5.12. The v6 entry — and with it the chalk@^2.4.1 → supports-color@^5.4.0 → vulnerable lodash chain — is completely gone.
3. Cascade Cleanup — chalk and supports-color
-"chalk@npm:^2.3.2, chalk@npm:^2.4.1, chalk@npm:^2.4.2":
+"chalk@npm:^2.3.2, chalk@npm:^2.4.2":
version: 2.4.2
-"supports-color@npm:^5.3.0, supports-color@npm:^5.4.0":
+"supports-color@npm:^5.3.0":
version: 5.5.0
Because the postcss 6.x entry was the only consumer of chalk@^2.4.1 and supports-color@^5.4.0, removing it also cleans up those specifier aliases from the lock file. This is a meaningful reduction in attack surface: the lock file no longer records resolution paths for dependency ranges that were solely serving the vulnerable subtree.
Prevention & Best Practices
1. Use resolutions (Yarn) or overrides (npm) for Transitive Dependencies
When a transitive dependency carries a known vulnerability and you can't immediately update the direct dependency that pulls it in, use your package manager's override mechanism:
// Yarn
"resolutions": { "lodash": ">=4.17.21" }
// npm
"overrides": { "lodash": ">=4.17.21" }
This is a targeted fix that doesn't require waiting for upstream maintainers.
2. Audit Your Lock File for Duplicate Major Versions
Multiple entries for the same package at different major versions (like postcss 6.x and 8.x coexisting) is a red flag. The older version may lack security patches that the newer one includes. Run:
yarn why postcss
# or
npm ls postcss
to visualize which packages are pulling in each version.
3. Validate Keys Before Deep Object Assignment
If you write any code that performs deep property assignment based on user-controlled input, always sanitize keys:
// Dangerous
function deepSet(obj, path, value) {
_.set(obj, path, value); // path from user input — never do this
}
// Safer
function deepSet(obj, path, value) {
const forbidden = ['__proto__', 'constructor', 'prototype'];
const parts = path.split('.');
if (parts.some(p => forbidden.includes(p))) {
throw new Error('Forbidden key in path');
}
_.set(obj, path, value);
}
4. Run Dependency Scanners in CI
Integrate Trivy, Snyk, or npm audit into your CI pipeline so vulnerable transitive dependencies are caught before they reach production:
# Example GitHub Actions step
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
5. Security Standards
- OWASP A06:2021 – Vulnerable and Outdated Components: This vulnerability is a textbook example of the risk posed by unmonitored transitive dependencies.
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes.
- Keep a Software Bill of Materials (SBOM) to track all direct and transitive dependencies and their versions.
Key Takeaways
- The postcss 6.0.23 entry in
yarn.lockwas the root of the problem — not postcss itself, but the legacy chalk/supports-color dependency chain it carried, which resolved to a vulnerable lodash version. - Yarn's
resolutionsfield is a surgical tool for forcing all consumers of a package to use a safe version, regardless of what semver range they specify — use it when transitive dependencies can't be updated through normal channels. - Two postcss major versions coexisting in
yarn.lock(^6.0.xand^8.4.x) doubled the attack surface; the fix reduces this to a single, pinned, patched entry. zipObjectDeepwith user-controlled paths is inherently dangerous — any code path that allows external input to drive deep object key assignment should be treated as a high-risk pattern.- Lock file hygiene matters for security — regularly audit
yarn.lockorpackage-lock.jsonfor stale major-version entries that may carry vulnerability chains the primary dependency has long since abandoned.
How Orbis AppSec Detected This
- Source: Crafted CSS input processed by the postcss pipeline, where user-influenced content can propagate into lodash utility functions via the postcss 6.x dependency chain.
- Sink:
lodash.zipObjectDeep()performing deep property assignment with unsanitized path keys, reachable through thepostcss@6.0.23→chalk@^2.4.1→supports-color@^5.4.0dependency path recorded inyarn.lock. - Missing control: No key sanitization to block
__proto__,constructor, orprototypesegments before deep object assignment; no resolution pin to prevent the vulnerable postcss 6.x subtree from being installed. - CWE: CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').
- Fix: Added a
resolutionsentry inpackage.jsonpinningpostcssto8.5.12and regeneratedyarn.lockto remove the vulnerablepostcss@6.0.23entry and its dependency chain entirely.
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
This fix is a good example of how transitive dependency hygiene directly affects application security. The project's own code never called zipObjectDeep — but the vulnerability was still present in the resolved dependency graph, reachable through a stale postcss 6.x entry that had quietly persisted in yarn.lock alongside the modern 8.x version.
The remediation is precise: a resolutions pin in package.json forces the entire dependency tree to use postcss 8.5.12, the lock file is regenerated to reflect a single clean entry, and the legacy chalk/supports-color chain that carried the lodash vulnerability is removed entirely. No application logic changed; only the dependency resolution did.
For developers: treat your lock file as a security artifact. Review it for duplicate major versions, run scanners against it in CI, and don't hesitate to use resolutions or overrides to enforce safe versions across your entire transitive dependency tree.