Security reference · Injection · JavaScript

MCP server prototype chain manipulation security

Prototype pollution is a JavaScript-specific vulnerability where an attacker can inject properties into Object.prototype — the base object from which all plain JavaScript objects inherit. Because every {} literal, every result of JSON.parse, and every object created without an explicit prototype inherits from Object.prototype, injecting a property there affects the entire Node.js process. In an MCP server, the attack path is typically through tool argument merging: a tool that calls lodash.merge(defaults, args) on untrusted input allows an attacker to set args["__proto__"]["isAdmin"] = true, making ({}).isAdmin === true everywhere in the server until the process restarts.

How prototype pollution works in MCP tools

A prototype pollution attack requires three conditions to be simultaneously true:

  1. Attacker-controlled JSON is parsed into an object (tool arguments, fetched API response, uploaded file)
  2. That object is merged into another object using a recursive merge function that doesn't sanitize keys
  3. The resulting polluted property is then checked somewhere in the codebase with an obj.property access pattern that falls through to the prototype chain

The classic lodash merge attack:

// Tool receives this as the args object (parsed from JSON):
const maliciousArgs = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Any code that calls JSON.parse on {"__proto__": ...} gets a plain object
// But it has a key literally named "__proto__" that lodash.merge will follow:
import _ from 'lodash';

const defaults = { theme: 'dark', pageSize: 20 };
const merged = _.merge({}, defaults, maliciousArgs);
// ^ lodash traverses "__proto__" key and sets Object.prototype.isAdmin = true

// Now in EVERY subsequent check in this process:
const user = { name: 'alice', role: 'viewer' };
console.log(user.isAdmin); // true — inherited from Object.prototype!

Process-wide impact: Prototype pollution is not scoped to a request, session, or tool call. Once Object.prototype is mutated, the change persists for the lifetime of the Node.js process and affects all concurrent and subsequent requests. A single tool call from a single attacker can compromise all other users' sessions.

Vulnerable merge patterns in MCP server code

PatternVulnerable?Notes
Object.assign({}, obj)NoShallow copy, does not recurse into nested objects. A __proto__ key is copied as a regular string key, not followed.
{ ...obj } spreadNoSpread is shallow. __proto__ key is not special in spread syntax.
_.merge(target, obj) (lodash 4.x)YesRecursive merge follows __proto__ and constructor.prototype keys. Fixed in lodash 4.17.21 for __proto__ but constructor.prototype remains an issue in some versions.
deepmerge(a, b)YesDefault config follows __proto__. Use isMergeableObject option to skip prototype-related keys.
JSON.parse(text)PartialNative JSON.parse treats __proto__ as a string key (not prototype mutation) since Node.js 12. But constructor and valueOf keys can still confuse downstream code.
secure-json-parseNoExplicitly blocks __proto__, constructor, and prototype keys, throwing on any attempt to use them.

Mitigation 1 — use secure-json-parse for untrusted JSON

import sjp from 'secure-json-parse';

// Throws if the JSON contains __proto__, constructor, or prototype keys
const safeArgs = sjp.parse(rawInput);

// For tool argument processing:
function parseToolArgs(raw) {
  try {
    return sjp.parse(raw, null, { protoAction: 'error', constructorAction: 'error' });
  } catch (err) {
    throw new Error('Invalid argument structure: prototype manipulation detected');
  }
}

Mitigation 2 — safe deep merge without prototype traversal

// Safe recursive merge that skips prototype-polluting keys
function safeMerge(target, source) {
  if (typeof source !== 'object' || source === null) return source;

  const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
  const result = Array.isArray(source) ? [] : Object.create(null);

  for (const key of Object.keys(source)) {
    if (FORBIDDEN_KEYS.has(key)) continue;  // skip entirely

    const val = source[key];
    if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
      result[key] = safeMerge(target?.[key] ?? Object.create(null), val);
    } else {
      result[key] = val;
    }
  }
  return result;
}

// Or use lodash.mergeWith with a customizer to block forbidden keys:
import _ from 'lodash';

function safeLodashMerge(target, source) {
  const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
  return _.mergeWith({}, target, source, (objVal, srcVal, key) => {
    if (FORBIDDEN.has(key)) return objVal; // keep original, discard source
    return undefined; // let lodash handle it normally
  });
}

Mitigation 3 — Object.freeze(Object.prototype)

The most robust defense is to freeze Object.prototype at process startup. Once frozen, any attempt to add a property to Object.prototype throws a TypeError in strict mode or silently fails in non-strict mode. Neither pollutes the prototype.

// Add at the VERY TOP of your server entry point — before any other imports
// (in the first module that Node.js evaluates)
Object.freeze(Object.prototype);
Object.freeze(Object.prototype.constructor);

// Verify it works:
try {
  Object.prototype.isAdmin = true;
} catch (err) {
  console.log('Prototype pollution attempt blocked:', err.message);
  // TypeError: Cannot add property isAdmin, object is not extensible
}

// Also freeze Function.prototype to prevent constructor-chain attacks
Object.freeze(Function.prototype);

Compatibility risk: Some npm packages legitimately add polyfills to Object.prototype at load time (this practice is deprecated but not extinct). If you freeze Object.prototype before loading such packages, they will silently fail to install their polyfills, causing subtle bugs. Audit your dependency tree before enabling this defense. Run your test suite after adding the freeze to catch breakage immediately.

Mitigation 4 — null-prototype objects for config stores

For configuration stores, option objects, and key-value registries that you control (not from untrusted input), create them with Object.create(null) rather than {}. These objects have no prototype chain — obj.hasOwnProperty doesn't exist, and pollution of Object.prototype doesn't affect them.

// Config store with no prototype — immune to pollution effects
const config = Object.create(null);
config.maxRetries = 3;
config.timeout = 10_000;

// Access patterns that work with null-prototype objects:
const val = config['maxRetries'];        // OK
const has = Object.prototype.hasOwnProperty.call(config, 'key');  // use this form
const keys = Object.keys(config);        // OK

Detecting prototype pollution in test suites

// Add to your test suite: verify no test mutates Object.prototype
beforeEach(() => {
  const snapshot = Object.keys(Object.prototype).join(',');
  // @ts-ignore
  global.__protoSnapshot = snapshot;
});

afterEach(() => {
  const current = Object.keys(Object.prototype).join(',');
  // @ts-ignore
  expect(current).toBe(global.__protoSnapshot);
  // If this fails, a test (or the code under test) polluted Object.prototype
});

SkillAudit findings for prototype chain manipulation

CRITICAL −22 Tool handler calls _.merge(target, args) or deepmerge(a, b) with untrusted argument input — prototype pollution via __proto__ key grants attacker-controlled properties to all objects in the process.
CRITICAL −20 No key sanitization in any recursive merge path — any tool call that merges user-supplied config, options, or metadata objects into internal structures is exploitable.
HIGH −16 Native JSON.parse used for untrusted input without secure-json-parse — while modern Node.js prevents direct prototype mutation via native parse, constructor keys cause downstream confusion in merge operations.
MEDIUM −10 Object.prototype not frozen at startup — no process-wide backstop against prototype pollution; any undetected merge path remains exploitable.

Run a prototype chain security audit on your MCP server at SkillAudit. The audit includes static analysis for unsafe merge calls, __proto__ key handling, and secure-json-parse adoption alongside the full security report.

Related references: memory exhaustion security · prompt injection defense · zero-trust MCP architecture