---
key: "prototype-pollution-boundaries"
title: "Keep untrusted object keys out of prototype and authority paths"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/prototype-pollution-boundaries/"
api: "https://softwaresecurity.com/api/v1/articles/prototype-pollution-boundaries.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-1321","CWE-915","A08:2025","A05:2025","API3:2023","V15.3.6","V15.3.3"]
tags: ["Access control","Input validation and parsing","JavaScript and Node.js"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Keep untrusted object keys out of prototype and authority paths

Trace an actual nested-merge mutation, replace it with a narrow schema, and make authorization independent of inherited properties.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/prototype-pollution-boundaries/

## In short

Do not merge untrusted objects recursively into ordinary objects. Validate each request against the exact fields the operation needs and build a fresh result from them. Store arbitrary keys in a `Map` or an `Object.create(null)` dictionary, and grant authority only from an own, server-created property checked with `Object.hasOwn` and strict equality. Runtime flags such as `--disable-proto` block only the `__proto__` path; `constructor.prototype` still works.

- `JSON.parse` creates an own `__proto__` key harmlessly; the later recursive merge or path assignment does the damage.
- Blocking `__proto__` alone leaves `constructor.prototype`, which reaches the same shared object.
- A schema that rejects unknown keys stops pollution and ordinary mass assignment of fields such as `isAdmin`.
- Read grants with `Object.hasOwn(session, "isAdmin") && session.isAdmin === true`, never through inheritance.
- Pollution matters because of gadgets: core Node.js APIs and popular apps have code that turns an inherited option into code execution.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Arbitrary-key data | `new Map()` or `Object.create(null)` | — | OWASP Prototype Pollution Prevention Cheat Sheet |
| Property checks | `Object.hasOwn(obj, key)` and strict comparison with the expected type | — | MDN: Object.hasOwn |
| Node.js runtime flag | `--disable-proto=delete` or `--disable-proto=throw` | Blocks only the `__proto__` accessor; in testing, `constructor.prototype` still polluted. | Node.js command-line options |
| Freezing intrinsics | `Object.freeze(Object.prototype)` at startup, or the experimental `--frozen-intrinsics` flag | Stops both paths in testing but can break libraries that patch built-ins; test the whole application. | Node.js command-line options |
| Node.js for the project | 22 or 24 (LTS lines); Node.js 20 reached end of life on 30 April 2026 | — | — |

## Verify it

1. Send `{"__proto__":{"isAdmin":true}}` and `{"constructor":{"prototype":{"isAdmin":true}}}` to every endpoint that merges input, then check `({}).isAdmin` in the same process.
2. Confirm a fresh object still lacks the property and that both requests were rejected by the schema.
3. Pollute `Object.prototype` in a test first, then call the authorization check and confirm it still denies.
4. Run the project with `node --test exercise.test.mjs` on Node.js 22 or 24 and confirm all 18 tests pass after the repair.
5. Search dependencies for deep-merge, `set`-by-path and query-string parsers, and check them against known advisories.

## What you will learn

- Identify the mutation point after JSON parsing.
- Replace arbitrary nested updates with an explicit schema.
- Keep arbitrary-key data and authorization independent of inherited properties.

Audience: JavaScript developers and reviewers of nested object updates, configuration loading and authorization code.

Before you start:

- Understand objects, JSON and property lookup.
- Distinguish request data from server-owned session state.

Time: About 31 minutes: 6 to read and 25 for the exercise and its local project

## Follow both the write and the later read

JavaScript property lookup can continue through an object’s prototype when an own property is absent. A prototype-pollution defect lets untrusted keys influence a write into that shared structure. Its impact appears later when unrelated code reads an inherited value as an option, authority field or other trusted input.

The supplied project has both halves: a recursive profile merge follows inherited `__proto__` or `constructor.prototype` objects, then a weak admin check trusts `session.isAdmin`. The test process observes the actual shared-prototype change and restores it after each case. This demonstrates the language behavior locally; whether a particular package or endpoint is affected needs its own test.

## Parsing the JSON is not the mutation

`JSON.parse` can create an own property named `__proto__` without changing the global prototype. The dangerous step is later dynamic assignment or recursion. A shallow setter-triggering assignment may change just the target’s prototype; a recursive traversal through an inherited object can reach `Object.prototype` itself. Identify which object is modified instead of treating every occurrence of the string as equivalent.

In the starter, `target[key] || {}` reuses an inherited object. For `key=constructor`, an ordinary object resolves the `Object` constructor; its `prototype` property leads to `Object.prototype`. Blocking only `__proto__` leaves this second path. Object spread does not invoke the `__proto__` setter the way assignment can, but spreading attacker fields still does not validate them or make them trustworthy.

## Replace the generic merge with the product’s actual update contract

This profile endpoint needs only `displayName` and `preferences.theme`. Accept a nonblank bounded string for `displayName` and an enumerated light/dark theme. Reject extra keys and wrong shapes at both levels. Construct a new result from the explicitly allowed fields; there is no product requirement to recursively apply arbitrary paths.

The distinction matters for testing. An `isAdmin` field should fail because it is not part of a profile update, even when it would be an ordinary own property and causes no prototype mutation; /fix/mass-assignment/ shows that repair for each common stack. A nested object where a string is expected should fail before downstream code uses it. Schema enforcement closes the accepted input contract, while prototype controls address object behavior.

The module below is the project's reference solution in readable form. `mergeProfile` builds a fresh null-prototype record from the two allowed fields, `canAdmin` accepts only an own boolean `true`, and `labelsDictionary` keeps arbitrary labels as inert data. It passes the project's 18 tests.

```javascript
// profile-boundary.mjs: the repaired boundaries, readable form of the project's reference.
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);

function allowKeys(value, allowed) {
  if (!isRecord(value) || Object.keys(value).some(key => !allowed.includes(key)))
    throw new Error('Invalid profile schema');
}

// Build a fresh record from the allowed fields; never merge arbitrary paths.
export function mergeProfile(profile, patch) {
  allowKeys(patch, ['displayName', 'preferences']);
  const result = Object.create(null);
  result.displayName = profile.displayName;
  result.preferences = Object.create(null);
  result.preferences.theme = profile.preferences.theme;
  if (Object.hasOwn(patch, 'displayName')) {
    const name = patch.displayName;
    if (typeof name !== 'string' || name.trim().length < 1 || name.length > 80)
      throw new Error('Invalid displayName');
    result.displayName = name;
  }
  if (Object.hasOwn(patch, 'preferences')) {
    allowKeys(patch.preferences, ['theme']);
    if (Object.hasOwn(patch.preferences, 'theme')) {
      if (!['light', 'dark'].includes(patch.preferences.theme)) throw new Error('Invalid theme');
      result.preferences.theme = patch.preferences.theme;
    }
  }
  return result;
}

// session is server-created state; only an own boolean true grants admin.
export function canAdmin(session) {
  return isRecord(session) && Object.hasOwn(session, 'isAdmin') && session.isAdmin === true;
}

// Arbitrary labels stay inert data in a dictionary with no prototype.
export function labelsDictionary(entries) {
  const dictionary = Object.create(null);
  for (const [key, value] of entries) {
    if (typeof key !== 'string' || key.length > 80 || typeof value !== 'string' || value.length > 200)
      throw new Error('Invalid label');
    dictionary[key] = value;
  }
  return dictionary;
}

```

## Use a dictionary when keys really are arbitrary data

`Object.create(null)` makes a dictionary without an inherited prototype chain. In this project, `labelsDictionary` intentionally stores the string keys `__proto__`, `constructor` and `hasOwnProperty` as inert own data. It accepts bounded string values and never interprets those labels as options, method names, paths or grants. A `Map` is another suitable representation for arbitrary key/value data.

The dictionary is safe within that declared use, not universally sanitized. Copying its arbitrary keys into another ordinary object with a setter-triggering merge can reintroduce a problem. Keep the representation and downstream contract intact. A null-prototype dictionary also lacks inherited convenience methods; use `Object.hasOwn(dictionary, key)` to inspect membership.

## Require a current, own, explicitly typed grant

The admin decision in this project requires `Object.hasOwn(session, "isAdmin")` and `session.isAdmin === true`. An inherited `true`, a string `"true"` and a number `1` are all denied. `Object.hasOwn` also works when an object has no prototype or an own field named `hasOwnProperty`. Calling `session.hasOwnProperty` directly would trust a method supplied by that object.

This check is meaningful only for server-created authenticated session state. A submitted JSON object containing its own `isAdmin:true` is still attacker-controlled. Do not route a validated profile patch into authorization. Session ownership, revocation and resource-specific permission (/domains/appsec/authorization/object-level-authorization/) remain separate checks; own-property testing establishes a property’s location, not its legitimacy.

## Know what pollution can reach, and what hardening covers

Pollution becomes dangerous when trusted code reads an option it never set. The Silent Spring study (USENIX Security 2023) found 11 such universal gadgets in core Node.js APIs, for example process-spawning options, and used them for eight remote code execution exploits in the npm CLI, Parse Server and Rocket.Chat. Treat any unchecked options object, template setting or environment map as a possible gadget, and keep merge, path-setting and query-parsing libraries patched.

Runtime hardening narrows the paths but does not replace the schema. In a test on Node.js 26 with a recursive merge, `--disable-proto=delete` stopped the `__proto__` payload and `--disable-proto=throw` made it throw, but the `constructor.prototype` payload polluted `Object.prototype` under both flags. Freezing intrinsics, with `Object.freeze(Object.prototype)` or the experimental `--frozen-intrinsics` flag, made both payloads throw, at the cost of breaking code that modifies built-ins.

| Runtime setting | Payload through \_\_proto\_\_ | Payload through constructor.prototype |
| --- | --- | --- |
| Default | Polluted | Polluted |
| `--disable-proto=delete` | Not polluted | Polluted |
| `--disable-proto=throw` | Throws `ERR_PROTO_ACCESS` | Polluted |
| `--frozen-intrinsics` or a frozen `Object.prototype` | Throws `TypeError` | Throws `TypeError` |

## A real-world failure

CVE-2019-10744 was a prototype pollution in lodash below 4.17.12: `defaultsDeep` could be tricked with a `constructor` payload into modifying `Object.prototype` for the whole process. The missing control was rejecting `__proto__` and `constructor` keys before merging, as this guide does.

## Test the boundaries independently

The project’s 18 tests preserve valid name/theme changes and genuine admin sessions while rejecting top-level and nested pollution routes, unknown fields, wrong scalar types and inherited grants. A dedicated test first pollutes the prototype independently of the merge, then exercises authorization. That proves the read-side defense still works if another component introduces a mutation.

Run `node --test exercise.test.mjs` in the extracted project with Node.js 22 or 24; Node.js 20 reached end of life on 30 April 2026. Repair `exercise.mjs` and keep the tests intact. After attempting the fix, compare `reference.mjs` or run `EXERCISE_MODULE=reference node --test exercise.test.mjs`. The suite uses no dependencies or network. Treat runtime hardening and maintained dependencies as additional protections, not substitutes for the schema and policy repairs.

| Boundary | Legitimate case | Negative case |
| --- | --- | --- |
| Profile selection | Change `displayName` or light/dark theme | Unknown authorization field or nested object in a string field |
| Write target | Construct fresh selected records | Recursion reaches `__proto__` or `constructor.prototype` |
| Admin read | Server-owned `isAdmin` boolean true | Inherited true or a truthy string |
| Arbitrary labels | Own string data in a null-prototype dictionary | Object-valued label later treated as options |

## Guided practice

Separate JSON parsing, the vulnerable write and the authorization read, then apply the real regression project.

### Supplied material

```text
Initial state: Object.prototype has no own isAdmin property.
Profile: {displayName:"Alice", preferences:{theme:"light"}}
Authenticated non-admin session: {} (ordinary object, no own isAdmin)
Incoming JSON A: {"__proto__":{"isAdmin":true}}
Incoming JSON B: {"constructor":{"prototype":{"isAdmin":true}}}
Incoming JSON C: {"preferences":{"theme":"dark"}}
Incoming JSON D: {"isAdmin":true}
Starter recursion:
  for each own [key,value] in input:
    if value is an object: target[key] = target[key] || {}; recurse(target[key],value)
    else: target[key] = value
Starter admin read: Boolean(session.isAdmin)
Target schema: only displayName:string (1..80 UTF-16 code units after nonblank check)
               and preferences:{theme:"light"|"dark"}; reject extra fields.
Independent label data: [["__proto__","label-a"],["hasOwnProperty","label-b"]]
Use a clean process/prototype for each independent case; the project does this cleanup.
```

### 1. Locate the mutation

For A, identify what changes after `JSON.parse` alone and what changes after the starter recursion.

Worked answer: Parsing produces an object with an own `__proto__` key. It does not by itself add `isAdmin` to `Object.prototype`. During recursion, the target’s inherited `__proto__` resolves to the shared prototype; the leaf assignment writes `isAdmin` there. A fresh ordinary session can then inherit true.

### 2. Find the second path

Explain why rejecting only `__proto__` is not enough for B.

Worked answer: An ordinary target inherits `constructor=Object`, and that function’s prototype is `Object.prototype`. The recursive merge follows the `constructor` then `prototype` path and reaches the same shared object. The profile schema should reject `constructor` at the first boundary; it has no valid purpose here.

### 3. Preserve valid behavior while rejecting unauthorized fields

Decide what the repaired profile endpoint does with C and D.

Worked answer: C produces Alice with theme dark and leaves the original profile unchanged. D fails the profile schema because `isAdmin` is not an allowed update. D need not pollute a prototype to be an unauthorized property update; blocking pollution alone would not solve this policy problem.

### 4. Check the read side independently

Assume another component has polluted `Object.prototype.isAdmin=true`. Decide the result for `{}`, `{isAdmin:false}`, `{isAdmin:"true"}` and trusted `{isAdmin:true}`.

Worked answer: Only the last trusted server-owned object grants admin. The empty object lacks an own grant; false denies; the string fails exact boolean comparison. Passing untrusted request JSON into this function remains forbidden even if that JSON contains an own true value.

### 5. Run the project and retain inert labels

Repair all three exported functions and run the 18 tests. Explain whether the two supplied label strings must be discarded.

Worked answer: Keep the strings as inert own keys in a null-prototype dictionary, with bounded string values and `Object.hasOwn` checks. They are not authorization or executable paths. All 18 tests should pass, including actual top-level/nested mutation attempts and the independent inherited-admin case. Record both the valid profile result and absence of a shared prototype change.

Deliverable: A write/read boundary trace, repaired schema and authorization functions, and passing evidence for all 18 local regression tests.

Scope: The project receives JSON-parsed values, not arbitrary getters or proxies. It demonstrates local JavaScript behavior and deliberately scoped profile policy; it does not establish safety of every dependency or downstream data consumer.

## Executable practice

- [Stop prototype pollution at merge and authorization boundaries: project ZIP](https://softwaresecurity.com/downloads/projects/prototype-pollution-2ef4d5a55b001cfe.zip) — Node.js 22+ · no dependencies

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [MDN — JavaScript prototype pollution](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/Prototype_pollution)
- [MDN — Object.hasOwn](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwn)
- [MDN — Object.create](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create)
- [OWASP — Prototype Pollution Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html)
- [CWE-1321 — Improperly controlled prototype modification](https://cwe.mitre.org/data/definitions/1321.html). CWE 4.20.
- [Node.js: --disable-proto and --frozen-intrinsics](https://nodejs.org/api/cli.html#--disable-protomode). Node.js v26.11.1.
- [Shcherbakov, Balliu and Staicu: Silent Spring, prototype pollution leads to RCE in Node.js (USENIX Security 2023)](https://arxiv.org/abs/2207.11171)
- [Node.js release schedule](https://github.com/nodejs/Release)
- [NVD: CVE-2019-10744, prototype pollution in lodash](https://nvd.nist.gov/vuln/detail/cve-2019-10744)