How prototype pollution via __proto__ key happens in Node.js defu and how to fix it
Introduction
In a frontend production application, the Trivy security scanner flagged a high-severity prototype pollution vulnerability (CVE-2026-35209) in defu version 6.1.4, a widely-used deep defaults merging utility in the Node.js ecosystem. The vulnerable package was pinned in frontend/pnpm-lock.yaml and consumed by critical configuration tooling—specifically c12 (a configuration loader) and the dotenv/config resolution pipeline.
The defu library's core purpose is deceptively simple: merge default values into objects without overwriting existing properties. But when that merging logic fails to reject the __proto__ key, an attacker who controls any input that flows through defu's merge path can inject properties onto every JavaScript object in the process.
This matters particularly here because the threat model identifies this as a local CLI tool where exploitation requires controlling command-line arguments or input files—a realistic scenario for supply chain attacks, malicious configuration files, or compromised development environments.
The Vulnerability Explained
What defu Does
The defu function recursively merges objects, applying "defaults" to a target. For example:
import { defu } from 'defu';
const config = defu(userConfig, {
port: 3000,
host: 'localhost'
});
If userConfig is { port: 8080 }, the result is { port: 8080, host: 'localhost' }. The user's values take precedence; defaults fill in the gaps.
The Flaw in 6.1.4
In defu@6.1.4, the recursive merge operation did not filter the __proto__ key when iterating over the defaults argument's properties. This means if an attacker could supply a defaults object like:
const maliciousDefaults = JSON.parse('{"__proto__": {"isAdmin": true}}');
And this object was passed through defu:
const result = defu({}, maliciousDefaults);
The merge would traverse into __proto__ and assign isAdmin: true to Object.prototype. After this pollution:
const anyObject = {};
console.log(anyObject.isAdmin); // true — every object is now "admin"
Attack Scenario Specific to This Codebase
Looking at the lockfile diff, defu@6.1.4 was consumed by two packages:
c12(configuration loader, version 3.3.3) — This library loads configuration from files (.configfiles,nuxt.config.ts, etc.) and merges them usingdefu.- A config resolution module using
confbox,dotenv, anddefutogether.
The attack path:
- An attacker crafts a malicious configuration file (e.g., a JSON or YAML config) containing a
__proto__key with injected properties. - The
c12configuration loader reads this file and passes it todefuas a defaults argument. defu@6.1.4recursively merges the__proto__key intoObject.prototype.- Downstream code that checks for properties like
isAdmin,allowUnsafe, ordebugon plain objects now finds attacker-controlled values.
In a CLI tool context, this could mean:
- Bypassing security checks in build pipelines
- Enabling debug/unsafe modes that expose secrets
- Causing denial of service through type confusion
The Fix
The fix is surgical: upgrade defu from 6.1.4 to 6.1.5, which adds explicit filtering of the __proto__ key during recursive merging.
Changes Made
frontend/package.json — Added explicit defu dependency pinned to the safe version:
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
+ "defu": "6.1.5",
"dotenv": "^17.2.3",
By adding defu as a direct dependency at version 6.1.5 (without a caret ^), the lockfile resolution is forced to use the patched version rather than the vulnerable 6.1.4 that transitive dependencies might resolve to.
frontend/pnpm-lock.yaml — The lockfile updates confirm the resolution change:
- defu@6.1.4:
- resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+ defu@6.1.5:
+ resolution: {integrity: sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==}
And critically, the consumers now resolve to 6.1.5:
dependencies:
c12: 3.3.3
consola: 3.4.2
- defu: 6.1.4
+ defu: 6.1.5
destr: 2.0.5
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
- defu: 6.1.4
+ defu: 6.1.5
dotenv: 17.2.4
Why This Works
Version 6.1.5 of defu adds a check that skips the __proto__ key (and likely constructor.prototype) during the recursive merge loop. This means even if a malicious object containing __proto__ is passed as a defaults argument, those dangerous keys are never traversed or assigned, preventing prototype pollution entirely.
Note that defu@6.1.7 also exists in the lockfile and is presumably safe as well, but the specific transitive dependencies (c12, the config module) were pinned to resolve 6.1.4. The direct dependency addition forces pnpm's resolution to prefer 6.1.5 for these consumers.
Prevention & Best Practices
-
Pin and audit transitive dependencies: Prototype pollution often lurks in transitive dependencies. Use
pnpm audit,npm audit, or Trivy to scan lockfiles regularly. -
Use
Object.create(null)for lookup maps: When creating objects used as dictionaries,Object.create(null)produces objects with no prototype, immune to pollution. -
Freeze prototypes in sensitive contexts:
javascript Object.freeze(Object.prototype);
This prevents modification but may break poorly-written libraries. -
Validate configuration input: Before passing parsed JSON/YAML through merge utilities, strip dangerous keys:
javascript function sanitize(obj) { if (typeof obj !== 'object' || obj === null) return obj; const { __proto__, constructor, ...safe } = obj; return Object.fromEntries( Object.entries(safe).map(([k, v]) => [k, sanitize(v)]) ); } -
Keep merge utilities updated: Libraries like
defu,lodash.merge,deepmerge, anddefaults-deephave all had prototype pollution CVEs. Subscribe to security advisories. -
Use Semgrep or CodeQL rules to detect unsafe object merging patterns in your own code.
Key Takeaways
defu@6.1.4's recursive merge did not filter__proto__keys, allowing any code path that passes attacker-influenced objects throughdefuto pollute the global prototype.- Transitive dependencies are the real attack surface: The vulnerable
defuversion wasn't a direct dependency—it was pulled in byc12and config resolution modules, making it invisible without lockfile scanning. - Configuration loaders are high-value targets for prototype pollution because they inherently parse and merge untrusted structured data (JSON, YAML, TOML files).
- Pinning a direct dependency to override transitive resolution (adding
"defu": "6.1.5"topackage.json) is an effective pnpm/npm strategy to force-patch vulnerable sub-dependencies. - CLI tools with file-based input are not safe from exploitation—if an attacker can influence a config file (via supply chain, shared repos, or social engineering), they can trigger prototype pollution.
How Orbis AppSec Detected This
- Source: Configuration files and CLI input parsed by
c12andconfbox-based config loaders, flowing intodefu's merge function as the defaults argument. - Sink:
defu@6.1.4's recursive object merge logic, which assigns properties from the defaults argument—including__proto__—onto the target object's prototype chain. - Missing control: No filtering or rejection of the
__proto__key during recursive property enumeration and assignment in the merge loop. - CWE: CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes)
- Fix: Upgraded
defufrom 6.1.4 to 6.1.5, which adds explicit__proto__key filtering during recursive defaults merging.
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 prototype pollution remains one of the most insidious vulnerability classes in the JavaScript ecosystem. The defu library is small, focused, and widely depended upon—exactly the kind of utility where a missing key check can cascade across thousands of projects. By upgrading from 6.1.4 to 6.1.5 and pinning the dependency explicitly, this fix eliminates the attack surface while maintaining full compatibility. If your project uses defu (directly or transitively through Nuxt, c12, unjs packages, or similar tooling), verify your lockfile resolves to ≥6.1.5 immediately.