Topic: mcp server prototype chain security

MCP server prototype pollution security — __proto__, constructor.prototype, Object.defineProperty

Prototype pollution is a JavaScript-specific vulnerability class where an attacker modifies the shared Object.prototype through a property assignment path that traverses the prototype chain. Any object in the same process that subsequently reads that property inherits the attacker's value. In MCP servers, prototype pollution typically arrives through tool parameters that are merged into configuration objects, processed by unsafe deep-merge utilities, or parsed by libraries with known prototype pollution vulnerabilities. The impact ranges from property injection (adding unexpected properties to all objects) to authorization bypass (overriding an isAdmin property that later code reads via prototype chain) to remote code execution (when the injected property is read by a code path that executes it).

Pattern 1: __proto__ injection via parameter merging

When an MCP tool merges user-supplied parameters into a configuration or options object, a parameter named __proto__ traverses the prototype chain and modifies Object.prototype. This is the classic prototype pollution vector.

In MCP servers, the most common merge patterns that expose this: spreading tool parameters into a config object ({ ...defaults, ...toolParams }), using lodash.merge to apply partial updates, or using a recursive merge utility to combine default options with tool-supplied overrides.

// WRONG: unsanitized deep merge of tool parameters into options
const _ = require('lodash');

async function configureTool_WRONG(toolParams) {
  const defaults = { timeout: 5000, retries: 3, debug: false };
  // If toolParams = { "__proto__": { "isAdmin": true } }
  // then after this merge, ALL objects in the process have isAdmin = true
  const options = _.merge({}, defaults, toolParams); // VULNERABLE
  return options;
}

// Demonstration of the impact:
// configureTool_WRONG({ "__proto__": { "isAdmin": true } })
// Then anywhere else:
const obj = {};
console.log(obj.isAdmin); // true — prototype polluted

// WRONG: recursive merge utility with same issue
function deepMerge_WRONG(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = deepMerge_WRONG(target[key] || {}, source[key]);
    } else {
      target[key] = source[key]; // assigns to target["__proto__"] → prototype chain
    }
  }
  return target;
}

// RIGHT: validate keys before merge
function sanitizeKeys(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  const safe = Object.create(null); // null prototype — no chain to pollute
  for (const key of Object.keys(obj)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue; // skip dangerous keys
    }
    safe[key] = typeof obj[key] === 'object' ? sanitizeKeys(obj[key]) : obj[key];
  }
  return safe;
}

async function configureTool(toolParams) {
  const defaults = { timeout: 5000, retries: 3, debug: false };
  // Sanitize before merge — strip __proto__, constructor, prototype keys
  const safeParams = sanitizeKeys(toolParams);
  // Use Object.assign (flat merge) — safe if safeParams has no __proto__
  const options = Object.assign({}, defaults, safeParams);
  return options;
}

// RIGHT: use Object.create(null) for dictionaries from user input
function buildQueryParams(userInput) {
  const params = Object.create(null); // no prototype — can't pollute Object.prototype
  for (const [key, value] of Object.entries(userInput)) {
    if (typeof key === 'string' && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
      params[key] = String(value); // coerce to string — no object traversal
    }
  }
  return params;
}

Pattern 2: prototype pollution via JSON reviver bypass

Modern V8 (Node 18.6+) makes JSON.parse safe against __proto__ injection — the parser treats __proto__ as a plain data key, not as a prototype access. However, libraries that implement their own JSON parsing or object construction (YAML parsers, TOML parsers, query string parsers) may not have this protection. Tool parameters that come from YAML configuration files or query string encodings can still carry prototype pollution payloads if parsed by vulnerable libraries.

// JSON.parse is safe in Node 18.6+ — __proto__ treated as data key
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log({}.polluted); // undefined — safe in modern Node

// WRONG: qs (query-string) library — some versions vulnerable
const qs = require('qs');
// ?__proto__[polluted]=true → Object.prototype.polluted = 'true' in old qs
const parsed = qs.parse('__proto__[polluted]=true');
console.log({}.polluted); // 'true' if qs < 6.9.8

// RIGHT: pin qs to 6.9.8+ (prototype pollution fix) and set allowPrototypes: false
const qs = require('qs');
const parsed = qs.parse(queryString, {
  allowPrototypes: false, // explicitly disallow prototype chain traversal
  allowDots: false,
  depth: 3 // limit nesting depth to reduce attack surface
});

// WRONG: yaml.load (js-yaml) with unsafe schema
const yaml = require('js-yaml');
// YAML with !!js/object notation can set __proto__
const config = yaml.load(untrustedYaml); // UNSAFE with DEFAULT_FULL_SCHEMA

// RIGHT: use yaml.load with SAFE schema
const config = yaml.load(untrustedYaml, { schema: yaml.DEFAULT_SAFE_SCHEMA });
// Or use the load function from 'yaml' package (v2) which defaults to safe parsing

Pattern 3: authorization bypass via polluted property

The most dangerous impact of prototype pollution in MCP servers is authorization bypass. If code anywhere in the server reads a property like isAdmin, role, allowed, or authenticated from an object without verifying that the property is an own property (not inherited from the prototype chain), a prototype pollution attack can set that property to true on Object.prototype and inherit it into every subsequent authorization check.

// WRONG: reads role from object — vulnerable to prototype pollution
async function checkAdmin_WRONG(session) {
  // If Object.prototype.isAdmin was set by a prototype pollution attack,
  // then ANY object including {} passes this check
  if (session.isAdmin) { // reads from prototype chain if not own property
    return true;
  }
  return false;
}

// WRONG: similar pattern with in operator
if ('isAdmin' in session) { // 'in' also checks prototype chain
  allowAdminAccess();
}

// RIGHT: use hasOwnProperty to check only own properties
async function checkAdmin(session) {
  // hasOwnProperty.call is safe even if session.hasOwnProperty was overridden
  if (Object.prototype.hasOwnProperty.call(session, 'isAdmin') &&
      session.isAdmin === true) {
    return true;
  }
  return false;
}

// RIGHT: freeze Object.prototype to prevent pollution entirely
// (run at server startup, before any untrusted input is processed)
Object.freeze(Object.prototype);
// After freeze: prototype pollution attempts throw in strict mode,
// silently fail in sloppy mode — either way, no modification occurs

Freezing Object.prototype at startup is the most robust defense: it prevents any code from modifying the prototype, regardless of where the vulnerability lives (your code, a dependency, or a transitive dependency). The trade-off is that some poorly-written libraries that extend Object.prototype will break — but in practice, no well-maintained library does this in 2026.

SkillAudit detection

SkillAudit's Security axis flags:

→ MCP server deserialization security — JSON.parse pollution, eval gadgets
→ MCP server template injection security — Handlebars, EJS, Pug SSTI
→ Input validation patterns for MCP server tool parameters