How Prototype Pollution Happens in JavaScript via defu and How to Fix It
Introduction
The pnpm-lock.yaml file is easy to overlook — it's auto-generated, rarely read by hand, and almost never the first place developers look for security issues. Yet it was exactly here that Trivy flagged CVE-2026-35209: a high-severity prototype pollution vulnerability in defu, a popular JavaScript utility used for deep-merging default values into objects.
defu is a transitive dependency in many modern JavaScript stacks. In this project, it flows in through h3 (the HTTP framework underpinning Nitro and Nuxt) and unstorage, both of which call defu to merge configuration and request defaults. Because these are production dependencies — not dev-only tooling — the vulnerability was reachable in a live environment.
The root cause: defu 6.1.4 did not sanitize __proto__ keys when recursively merging a defaults object into a target. Passing { "__proto__": { "isAdmin": true } } as a defaults argument would silently walk up the prototype chain and inject isAdmin onto Object.prototype itself — making every plain object in the Node.js process suddenly report isAdmin === true.
The Vulnerability Explained
What is Prototype Pollution?
Every JavaScript object inherits from Object.prototype. When a merge utility blindly iterates over the keys of an input object and writes them to a target, a specially crafted key like __proto__ doesn't create a property named __proto__ — it traverses the prototype chain and writes to the prototype of the target's constructor. If the target is a plain object ({}), that prototype is Object.prototype itself.
// Conceptual illustration of the vulnerable merge pattern in defu 6.1.4
function merge(target, defaults) {
for (const key in defaults) {
if (typeof defaults[key] === 'object') {
merge(target[key], defaults[key]); // ← __proto__ key traverses prototype chain
} else {
target[key] = defaults[key];
}
}
}
// Attacker-supplied payload
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
// Every plain object now has isAdmin === true
console.log({}.isAdmin); // true ← prototype has been polluted
The vulnerable version locked in pnpm-lock.yaml was:
# BEFORE (vulnerable)
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
Attack Scenario Specific to This Codebase
Consider how h3 uses defu internally: when processing an incoming HTTP request, h3 merges route-level defaults with request-level options. If any part of that merge path accepts attacker-influenced data — such as a JSON request body, query parameters parsed into an object, or externally loaded configuration — an attacker could craft a payload like:
{
"__proto__": {
"admin": true,
"bypassRateLimit": true
}
}
Once defu processes this through its recursive merge, Object.prototype.admin becomes true for the entire lifetime of the Node.js process. Subsequent authorization checks of the form if (user.admin) would pass for every user, even unauthenticated ones, because {}.admin now resolves to true via the prototype chain.
Similarly, unstorage uses defu to merge storage driver options. A polluted prototype could alter the behavior of storage operations across all drivers in the same process.
Real-World Impact
- Privilege escalation: Authorization checks relying on property existence (
obj.isAdmin,obj.role) can be bypassed. - Denial of service: Injecting properties that conflict with internal Node.js or framework internals can crash the process.
- Logic tampering: Feature flags, configuration values, and behavioral switches can be overridden globally.
- Severity: HIGH — the vulnerability is reachable through production HTTP request handling paths.
The Fix
The fix involves three coordinated changes across two files.
1. Upgrade the resolved version in pnpm-lock.yaml
# BEFORE
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
# AFTER
defu@6.1.5:
resolution: {integrity: sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==}
Changing the integrity hash is not cosmetic — it cryptographically pins the download to the patched release. Any tampering with the package would cause pnpm to reject the install.
2. Add a global override in pnpm-lock.yaml
# Added at the top of pnpm-lock.yaml
overrides:
defu: 6.1.5
This is critical. Without this override, pnpm might resolve defu@6.1.4 for transitive dependents (h3, unstorage) that declare a semver range like ^6.1.4, which technically satisfies 6.1.4. The override forces every package in the dependency tree to use 6.1.5, regardless of what their individual package.json specifies.
3. Add the override to pnpm-workspace.yaml
# pnpm-workspace.yaml (added)
overrides:
defu: "6.1.5"
This ensures the override is respected at the workspace level, covering all packages in a monorepo setup. Without this, workspace packages that install independently could still resolve the vulnerable version.
Snapshot update
The dependency snapshot entry was also updated:
# BEFORE
defu@6.1.4: {}
# AFTER
defu@6.1.5: {}
And the unstorage snapshot's resolved dependency was updated accordingly:
# BEFORE (in unstorage snapshot)
defu: 6.1.4
# AFTER
defu: 6.1.5
Why 6.1.5 Fixes the Problem
defu 6.1.5 adds explicit key sanitization during recursive merging. Keys equal to __proto__, constructor, and prototype are now skipped, preventing the prototype chain traversal that made the attack possible. The fix is minimal, backward-compatible, and does not change the public API.
Prevention & Best Practices
1. Always sanitize merge keys in custom utilities
If you write your own object-merging logic, explicitly block dangerous keys:
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.has(key)) continue; // ← block prototype pollution
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = safeMerge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
2. Use Object.create(null) for data objects
When building objects that will hold user-supplied data, use Object.create(null) to create prototype-less objects. These cannot be used as a pollution vector because they have no __proto__:
const safeStore = Object.create(null);
safeStore.__proto__ = "attack"; // just sets a string key, doesn't touch prototype
3. Pin transitive dependencies with lockfile overrides
As demonstrated in this fix, pnpm's overrides field (and npm's overrides / yarn's resolutions) lets you force a specific version of a transitive dependency across the entire tree. Use this pattern whenever a CVE is published for a package you don't directly control:
# pnpm-workspace.yaml
overrides:
defu: "6.1.5"
4. Freeze Object.prototype in sensitive environments
For high-security applications, consider freezing the prototype at startup:
Object.freeze(Object.prototype);
This prevents any code from modifying Object.prototype at runtime, turning a silent pollution into a thrown TypeError.
5. Scan your lockfile, not just your direct dependencies
Trivy flagged this vulnerability in pnpm-lock.yaml — not in package.json. Many teams scan only their direct dependencies. Transitive dependency scanning is essential because vulnerabilities frequently arrive through indirect packages like defu that you never explicitly installed.
Standards References
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- OWASP A08:2021: Software and Data Integrity Failures
- OWASP A06:2021: Vulnerable and Outdated Components
Key Takeaways
defu6.1.4's recursive merge did not block__proto__keys, making it possible to poisonObject.prototypethrough any code path that passes attacker-influenced data todefu's defaults argument.- The
pnpm-lock.yamlintegrity hash change is security-critical — it cryptographically pins the download to the patched 6.1.5 release and prevents silent rollback to the vulnerable version. - A lockfile override alone is not enough — the fix required coordinated changes to both
pnpm-lock.yaml(theoverridesblock) andpnpm-workspace.yamlto ensure all transitive consumers (h3,unstorage) resolve the patched version. - Prototype pollution in a shared HTTP framework dependency like
h3is particularly dangerous because the polluted prototype persists for the entire process lifetime and affects all requests handled after the attack. - Lockfile-level scanning (Trivy) caught this where application-level scanning would have missed it — the vulnerability was in a transitive dependency not visible in
package.json.
How Orbis AppSec Detected This
- Source: The
__proto__key in a user-influenced object passed as thedefaultsargument todefu's merge function. - Sink:
defu's recursive object merger in version 6.1.4, called internally byh3(HTTP request handling) andunstorage(storage driver configuration) — both production dependencies resolved inpnpm-lock.yaml. - Missing control: No sanitization of
__proto__,constructor, orprototypekeys before recursive property assignment indefu@6.1.4. - CWE: CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').
- Fix: Upgraded
defufrom 6.1.4 to 6.1.5 and added workspace-level overrides in bothpnpm-lock.yamlandpnpm-workspace.yamlto force all transitive dependents to resolve the patched version.
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-35209 is a textbook example of why transitive dependency security matters as much as direct dependency security. The defu library — a small, focused utility for merging default values — introduced a high-severity prototype pollution vulnerability that could have allowed attackers to corrupt Object.prototype across an entire Node.js process, bypassing authorization checks and altering application behavior globally.
The fix was precise and surgical: upgrade defu to 6.1.5, add lockfile overrides to force the patched version for all transitive consumers, and verify the cryptographic integrity hash. No application logic changed. The attack surface was closed.
The broader lesson is this: your application's security posture is only as strong as your deepest transitive dependency. Scanning lockfiles, enforcing version overrides, and automating CVE remediation are not optional hygiene — they are essential practices for production JavaScript applications.