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.

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.
Applies to
  • Node.js 22 and 24 (LTS); hardening flags tested on 26.3

JavaScript object merges, nested assignments and authorization lookups receiving untrusted JSON.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 31 minutes: 6 to read and 25 for the exercise and its local project

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.

Before you start

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

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

Refresh first: Keep untrusted values out of executable syntax

Go to the guided practice

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.

Fixedprofile-boundary.mjsJavaScript
// 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.

Know what pollution can reach, and what hardening covers
Runtime settingPayload through __proto__Payload through constructor.prototype
DefaultPollutedPolluted
--disable-proto=deleteNot pollutedPolluted
--disable-proto=throwThrows ERR_PROTO_ACCESSPolluted
--frozen-intrinsics or a frozen Object.prototypeThrows TypeErrorThrows 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.

Test the boundaries independently
BoundaryLegitimate caseNegative case
Profile selectionChange displayName or light/dark themeUnknown authorization field or nested object in a string field
Write targetConstruct fresh selected recordsRecursion reaches __proto__ or constructor.prototype
Admin readServer-owned isAdmin boolean trueInherited true or a truthy string
Arbitrary labelsOwn string data in a null-prototype dictionaryObject-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 scenario material
Plain 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. 1. Locate the mutation

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

    Expected result and reasoning

    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. 2. Find the second path

    Explain why rejecting only __proto__ is not enough for B.

    Expected result and reasoning

    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. 3. Preserve valid behavior while rejecting unauthorized fields

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

    Expected result and reasoning

    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. 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}.

    Expected result and reasoning

    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. 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.

    Expected result and reasoning

    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.

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

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22+ · no dependencies · 45–60 min

Stop prototype pollution at merge and authorization boundaries

Observe real prototype mutation from nested JSON merges, then repair schema selection, own-property authorization and arbitrary-key dictionaries without breaking valid updates.

In the extracted folder, run:

Shell
node --test exercise.test.mjs

18 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.

Part of the learning path Browser and API boundaries

Real JavaScript behavior in a local isolated test process; JSON inputs and synthetic session state. No service or third-party library vulnerability is targeted.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. MDN — JavaScript prototype pollution
  2. MDN — Object.hasOwn
  3. MDN — Object.create
  4. OWASP — Prototype Pollution Prevention Cheat Sheet
  5. CWE-1321 — Improperly controlled prototype modification. CWE 4.20.
  6. Node.js: --disable-proto and --frozen-intrinsics. Node.js v26.11.1.
  7. Shcherbakov, Balliu and Staicu: Silent Spring, prototype pollution leads to RCE in Node.js (USENIX Security 2023)
  8. Node.js release schedule
  9. NVD: CVE-2019-10744, prototype pollution in lodash

Reference information

Node.js 22 or 24 for the local project; Node.js 20 is end of life. Merge libraries and framework versions differ, so test the one you use.

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-15.3.6 · v5.0.0-15.3.3

Download this guide as Markdown