How Prototype Pollution happens in Node.js and how to fix it
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Prototype Pollution via unsafe JSON parsing |
| CWE | CWE-1321 |
| Language | JavaScript (Node.js) |
| Risk | Attacker corrupts global Object prototype, affecting all runtime objects |
| Root Cause | request.json() parsed untrusted HTTP bodies without filtering __proto__ or constructor keys |
| Fix | JSON.parse(rawText, reviverFn) with a reviver that strips dangerous keys |
Introduction
The worker/import-core.js file handles inbound import requests — it reads a JSON specification from an HTTP request body and builds an internal map from it. On the surface, this looks like straightforward request handling. But a single line in the handleImport function created a critical security hole: the request body was parsed with request.json() and handed directly to application logic without any sanitization.
// Line 25 — the vulnerable pattern
let spec; try { spec = await request.json(); } catch (e) { return J(400, { error: 'body must be valid JSON' }); }
The problem is not that JSON is being parsed — it's how it's being parsed. request.json() in the Cloudflare Workers runtime (and in most JS environments) is a thin wrapper around JSON.parse. It does not filter or reject keys like __proto__ or constructor. An attacker who can reach this endpoint can send a payload that mutates Object.prototype itself, corrupting the behavior of every plain object created after that point in the same runtime isolate.
The Vulnerability Explained
What is Prototype Pollution?
Every JavaScript object inherits properties from its prototype chain. Object.prototype sits at the top of that chain, meaning any property added to it becomes visible on every plain object in the runtime. Prototype pollution is the act of exploiting a JSON parser (or a deep merge function) to write attacker-controlled values onto that shared prototype.
The attack vector is deceptively simple. Consider this JSON payload:
{
"__proto__": {
"isAdmin": true
}
}
When JSON.parse processes this naively, it creates an object with a __proto__ key. In older Node.js versions and some environments, assigning to __proto__ during object construction directly mutates Object.prototype. Even in environments where direct mutation is blocked at assignment time, deeply nested payloads using constructor.prototype can achieve the same result:
{
"constructor": {
"prototype": {
"isAdmin": true
}
}
}
The Specific Vulnerable Code
In worker/import-core.js, the handleImport function consumed the entire request body through request.json():
// BEFORE — vulnerable
let spec; try { spec = await request.json(); } catch (e) { return J(400, { error: 'body must be valid JSON' }); }
let map; try { map = buildMapFromSpec(spec); } catch (e) { return J(400, { error: String(e && e.message || e) }); }
The parsed spec object is then passed directly into buildMapFromSpec(spec). If spec contains a __proto__ or constructor key, those keys may be processed by buildMapFromSpec or any downstream function that iterates object properties — spreading prototype pollution throughout the runtime.
Real-World Attack Scenario
An attacker sends a POST request to the import endpoint with a crafted body:
POST /import HTTP/1.1
Authorization: Bearer <valid-token>
Content-Type: application/json
{
"__proto__": { "polluted": true },
"nodes": [...]
}
Because the Authorization header check passes (the attacker has a valid token, or the token is not configured), the request reaches request.json(). The resulting spec object carries the __proto__ key. Depending on the JavaScript engine version and how buildMapFromSpec processes the spec, this can:
- Inject properties into all subsequent plain objects — any
{}literal created later in the same isolate may unexpectedly havepolluted: true. - Bypass authorization checks — if any downstream code does
if (obj.isAdmin)without anObject.hasOwnguard, a polluted prototype can make that check pass for every object. - Cause denial of service — overwriting
Object.prototype.toStringorObject.prototype.hasOwnPropertybreaks fundamental operations across the entire runtime. - Enable remote code execution — in server-side template engines or
eval-adjacent code paths, prototype pollution can escalate to RCE.
This is particularly dangerous because worker/import-core.js is described as a Node.js library — meaning downstream consumers who import this package inherit the vulnerability.
The Fix
What Changed
The fix replaces the one-liner request.json() call with a two-step process: first read the raw text with request.text(), then parse it using JSON.parse with a reviver function that explicitly returns undefined for any key named __proto__ or constructor.
// AFTER — safe
let spec; try { const _t = await request.text(); spec = JSON.parse(_t, (k, v) => (k === '__proto__' || k === 'constructor') ? undefined : v); } catch (e) { return J(400, { error: 'body must be valid JSON' }); }
Before vs. After
Before (vulnerable):
spec = await request.json();
After (safe):
const _t = await request.text();
spec = JSON.parse(_t, (k, v) => (k === '__proto__' || k === 'constructor') ? undefined : v);
Why This Fix Works
The JSON.parse reviver function is called for every key-value pair before the object is assembled. When the reviver returns undefined for a key, that key is completely omitted from the resulting object — it is never assigned, never iterated, and never reaches buildMapFromSpec. The dangerous keys are neutralized at the earliest possible point in the data pipeline.
This is the correct approach because:
- It operates at parse time, not after the object is constructed. Filtering keys after
JSON.parseis too late if the runtime has already processed__proto__during object construction. - It is zero-dependency. No additional library is needed; the reviver is a native capability of
JSON.parsedefined in the ECMAScript specification. - It is composable. The reviver can be extended to block additional dangerous keys (e.g.,
"__defineGetter__","__defineSetter__") without changing the surrounding logic. - It preserves all legitimate data. The reviver only strips the two specifically dangerous keys; all other keys pass through unchanged, so
buildMapFromSpecreceives a clean, structurally equivalent object.
Prevention & Best Practices
1. Always Use a Reviver When Parsing Untrusted JSON
Never call JSON.parse on untrusted input without a reviver or a schema validator. A minimal safe reviver looks like this:
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeReviver(key, value) {
if (DANGEROUS_KEYS.has(key)) return undefined;
return value;
}
const parsed = JSON.parse(untrustedString, safeReviver);
2. Consider secure-json-parse or destr for Shared Libraries
For libraries consumed by many downstream projects, consider using a dedicated safe-parse library:
secure-json-parse— drops dangerous keys and optionally throwsdestr— a fast, safe alternative toJSON.parse
3. Validate Against a Schema After Parsing
Even with a safe reviver, validate the parsed object against a known schema using a library like zod, ajv, or typebox. Schema validation catches unexpected shapes that could still trigger logic bugs downstream:
import { z } from 'zod';
const SpecSchema = z.object({
nodes: z.array(z.object({ id: z.string() })),
edges: z.array(z.object({ source: z.string(), target: z.string() })),
});
const spec = SpecSchema.parse(JSON.parse(_t, safeReviver));
4. Freeze Object.prototype in Critical Environments
In environments where you control the entire runtime, you can add a defense-in-depth measure:
Object.freeze(Object.prototype);
This prevents any code from adding properties to Object.prototype at runtime. Note that this may break some third-party libraries that rely on prototype augmentation.
5. Use Object.create(null) for Data Bags
When creating objects that are used purely as data containers (maps, dictionaries), use Object.create(null) instead of {}. These objects have no prototype and are therefore immune to prototype pollution:
const dataMap = Object.create(null);
dataMap['key'] = 'value'; // safe — no prototype chain
6. Lint for Unsafe JSON Parsing
Add a Semgrep rule to your CI pipeline to flag request.json() or bare JSON.parse calls on request bodies:
rules:
- id: unsafe-json-parse
pattern: JSON.parse($INPUT)
message: "Use JSON.parse with a reviver to prevent prototype pollution"
severity: WARNING
languages: [javascript, typescript]
OWASP & CWE References
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- OWASP A03:2021 — Injection (prototype pollution is a form of injection into the object model)
- OWASP Input Validation Cheat Sheet — always validate and sanitize parsed data structures
Key Takeaways
request.json()is not safe for untrusted input — inworker/import-core.js, it silently passed__proto__andconstructorkeys straight into application logic without any filtering.- The reviver function in
JSON.parseis the correct fix — it strips dangerous keys before the object is constructed in memory, not after. - Prototype pollution in a shared library is a supply-chain risk — because
import-core.jsis consumed by downstream projects, a single exploitable endpoint could pollute the prototype in every consumer's runtime. - Authorization checks are not a sufficient defense — the vulnerability exists even for authenticated users; any valid token holder (or a misconfigured instance with no token) could trigger it.
buildMapFromSpecwas one function call away from processing poisoned input — the lack of a sanitization boundary between the HTTP layer and application logic is the root architectural mistake.
How Orbis AppSec Detected This
- Source: The HTTP request body received by
handleImportinworker/import-core.js, parsed viarequest.json()at line 25. - Sink: The parsed
specobject passed directly intobuildMapFromSpec(spec)at line 26, where attacker-controlled keys including__proto__andconstructorcould influence object construction. - Missing control: No reviver function, no schema validation, and no key filtering between the HTTP layer and application logic. The raw output of
request.json()was used without any sanitization boundary. - CWE: CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- Fix: Replaced
request.json()withrequest.text()followed byJSON.parse(rawText, (k, v) => (k === '__proto__' || k === 'constructor') ? undefined : v), stripping dangerous keys at parse time.
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
Prototype pollution is one of those vulnerabilities that looks harmless in isolation — after all, it's "just" a JSON parsing call — but its blast radius is enormous. By mutating Object.prototype, an attacker can corrupt the behavior of every plain object in the runtime, bypass authorization logic, cause denial of service, and in some environments escalate to remote code execution. In worker/import-core.js, the handleImport function was one crafted HTTP request away from exposing the entire application runtime to this risk.
The fix is elegant in its simplicity: a five-word reviver function (k, v) => dangerous ? undefined : v placed at the exact point where untrusted data enters the system. This is the right pattern — sanitize at the source, before the data touches any application logic.
If you're writing Node.js services that parse JSON from HTTP requests, audit every request.json() and bare JSON.parse() call in your codebase. Ask whether the parsed object could contain __proto__ or constructor keys, and whether your downstream code is protected against prototype mutation. In most cases, it isn't — and the fix is exactly as simple as the one demonstrated here.