Back to Blog
critical SEVERITY9 min read

How Prototype Pollution happens in Node.js and how to fix it

A critical prototype pollution vulnerability was discovered in `worker/import-core.js`, where `request.json()` parsed untrusted HTTP request bodies without filtering dangerous keys like `__proto__` and `constructor`. An attacker could send a crafted JSON payload to corrupt the global `Object` prototype, potentially affecting every object in the application runtime. The fix replaces the unsafe parse with a JSON reviver function that strips these dangerous keys before any object is constructed.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a Prototype Pollution vulnerability (CWE-1321) in a Node.js Cloudflare Worker endpoint (`worker/import-core.js`). The `handleImport` function called `request.json()` directly on untrusted HTTP input, allowing attackers to inject `__proto__` or `constructor` keys and mutate the global Object prototype. The fix replaces `request.json()` with `JSON.parse(text, reviverFn)` using a reviver that returns `undefined` for those dangerous keys, preventing prototype mutation before any application logic runs.

Vulnerability at a Glance

cweCWE-1321
fixReplaced `request.json()` with `JSON.parse(rawText, reviverFn)` that strips dangerous keys via a reviver function
riskAttacker can corrupt the global Object prototype, affecting all objects in the runtime
languageJavaScript (Node.js)
root cause`request.json()` parsed untrusted HTTP bodies without filtering `__proto__` or `constructor` keys
vulnerabilityPrototype Pollution via unsafe JSON parsing

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:

  1. Inject properties into all subsequent plain objects — any {} literal created later in the same isolate may unexpectedly have polluted: true.
  2. Bypass authorization checks — if any downstream code does if (obj.isAdmin) without an Object.hasOwn guard, a polluted prototype can make that check pass for every object.
  3. Cause denial of service — overwriting Object.prototype.toString or Object.prototype.hasOwnProperty breaks fundamental operations across the entire runtime.
  4. 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.parse is 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.parse defined 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 buildMapFromSpec receives 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 throws
  • destr — a fast, safe alternative to JSON.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 — in worker/import-core.js, it silently passed __proto__ and constructor keys straight into application logic without any filtering.
  • The reviver function in JSON.parse is 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.js is 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.
  • buildMapFromSpec was 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 handleImport in worker/import-core.js, parsed via request.json() at line 25.
  • Sink: The parsed spec object passed directly into buildMapFromSpec(spec) at line 26, where attacker-controlled keys including __proto__ and constructor could 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() with request.text() followed by JSON.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.


References

Frequently Asked Questions

What is prototype pollution?

Prototype pollution is an attack where an attacker injects or modifies properties on JavaScript's `Object.prototype`, causing those properties to appear on every object in the runtime, potentially enabling privilege escalation, denial of service, or remote code execution.

How do you prevent prototype pollution in Node.js?

Use a JSON reviver function with `JSON.parse` that returns `undefined` for keys like `__proto__` and `constructor`, or use a library like `destr` or `secure-json-parse`. You can also use `Object.create(null)` for data objects and freeze the prototype with `Object.freeze(Object.prototype)`.

What CWE is prototype pollution?

Prototype pollution is classified as CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution').

Is input validation alone enough to prevent prototype pollution?

Not always. String-level validation may miss nested `__proto__` keys in deeply structured JSON. The safest approach is to filter dangerous keys at the `JSON.parse` level using a reviver function, which intercepts keys before any object is created in memory.

Can static analysis detect prototype pollution?

Yes. Tools like Semgrep, CodeQL, and Snyk can detect patterns where `JSON.parse` or equivalent calls process untrusted input without a reviver or schema validation. Orbis AppSec's multi-agent AI scanner flagged this exact pattern in `worker/import-core.js`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #6

Related Articles

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

high

How JavaScript Injection via String Interpolation Happens in Go Wails Applications and How to Fix It

A high-severity JavaScript injection vulnerability in `internal/clusterconfigs/input.go` allowed arbitrary code execution through malicious kubeconfig filenames. The `saveClusterConfigFile` function at line 20 constructed JavaScript code by directly interpolating unsanitized filenames into `window.ExecJS()` calls, enabling attackers to break out of string literals and execute arbitrary JavaScript in the Webview context.

high

How Denial of Service via Prototype Pollution happens in Axios and how to fix it

Axios versions prior to 1.15.1 merged untrusted configuration objects without guarding against the `__proto__` key, letting attacker-controlled input pollute `Object.prototype` and crash or destabilize applications. Upgrading axios (and its transitive dependencies `form-data`, `follow-redirects`, `proxy-from-env`) closes this Denial of Service and prototype-pollution attack surface without changing any application code.

critical

How Server-Side Request Forgery happens in Node.js and how to fix it

The order-flow service in a Node.js e-commerce backend built an outbound fetch() URL by directly concatenating a configurable `sendingOrder.url` value with a query string, with no validation of protocol or destination. This allowed order data—including customer and payment-adjacent information—to be silently redirected to an attacker-controlled endpoint simply by changing a config value or environment variable.

high

How Infinite Loop Denial of Service Happens in nanoid and How to Fix It

CVE-2026-67213 is a high-severity infinite loop vulnerability in nanoid's `customAlphabet` function that could cause Denial of Service through CPU exhaustion. The fix upgrades nanoid from 3.3.12 to patched versions 3.3.18 and 5.1.6, eliminating the loop condition that trapped ID generation when processing certain input patterns.

critical

How Message Corruption via Protocol Length Header Abuse Happens in WebSocket Implementations and How to Fix It

CVE-2026-54466 is a critical vulnerability in websocket-driver 0.7.4 that allows attackers to corrupt WebSocket messages by abusing protocol length headers. The fix upgrades the package to version 0.7.5, which implements proper validation of untrusted length header inputs. This vulnerability could allow attackers to modify or inject data into real-time communication channels used by frontend applications.