MCP Server Security
Prototype pollution security in MCP servers
Prototype pollution lets an attacker inject properties into Object.prototype, causing those properties to appear on every plain object in the process. In an MCP server, a single vulnerable merge on LLM-supplied tool arguments can pollute the entire runtime — disabling auth checks, adding bypass flags, or crashing the server.
What prototype pollution is
Every JavaScript object inherits from Object.prototype. If an attacker can set Object.prototype.isAdmin = true, then every object in the process that checks obj.isAdmin without an own-property guard will see true. The attack vector is a recursive merge function that blindly copies properties — including the special key __proto__ — from attacker-supplied JSON into application objects.
The attack pattern via JSON input
// Vulnerable recursive merge
function merge(target: any, source: any): any {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {}
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
// Attacker sends this as tool arguments:
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}')
merge({}, malicious)
// Now every object in the process sees isAdmin: true
console.log({}.isAdmin) // true ← global pollution
The for...in loop iterates inherited properties including __proto__. Setting target['__proto__'] on a plain object in this context modifies Object.prototype directly. The JSON key __proto__ is the classic vector; constructor.prototype is a second variant that bypasses some naive key-name filters.
Why MCP servers are a higher-risk target
MCP tool arguments are parsed from JSON and often passed through configuration merge patterns — combining tool defaults with user overrides, merging session config with tool config, or deep-cloning options. Any of these merge points can be vulnerable if they don't guard against prototype property injection. The LLM intermediary means an attacker who can influence the model's context (via prompt injection) can craft tool arguments containing the __proto__ key.
Checking for hasOwnProperty safely
The standard fix for the for...in loop is to add a hasOwnProperty guard. But the guard itself has a bypass: if the attacker has already polluted Object.prototype.hasOwnProperty, the guard is compromised. Use the inherited-safe form instead:
// Unsafe — hasOwnProperty can be shadowed
for (const key in source) {
if (source.hasOwnProperty(key)) { ... } // vulnerable if already polluted
}
// Safe — calls Object.prototype.hasOwnProperty directly, cannot be shadowed
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) { ... }
}
// Also safe — block __proto__ and constructor explicitly
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
for (const key in source) {
if (BLOCKED_KEYS.has(key)) continue
if (Object.prototype.hasOwnProperty.call(source, key)) { ... }
}
Object.create(null) — the clean-object pattern
The most reliable defense for configuration objects and property maps is to create them without a prototype chain at all. Object.create(null) produces an object with no __proto__, no inherited hasOwnProperty, and no vulnerability to prototype chain pollution:
// Config store with no prototype — safe to accumulate external properties
const toolConfig = Object.create(null) as Record<string, unknown>
// Lookup must use explicit key check, not 'in' or inherited methods
function getConfig(key: string): unknown {
return Object.prototype.hasOwnProperty.call(toolConfig, key)
? toolConfig[key]
: undefined
}
// Merging into a null-prototype object cannot pollute Object.prototype
function safeMergeConfig(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const key of Object.keys(source)) { // Object.keys — own enumerable only
if (key === '__proto__' || key === 'constructor') continue
target[key] = source[key]
}
}
Safe deep merge alternatives
Rather than writing your own recursive merge, use a battle-tested library that explicitly guards against prototype pollution:
# deepmerge@4+ — guards __proto__ and constructor.prototype npm install deepmerge # or use structuredClone (Node 17+) for deep copies without merge const copy = structuredClone(source)
import merge from 'deepmerge' // deepmerge blocks __proto__ key by default since 4.0 const merged = merge(defaults, userOverrides) // For deep copy without needing a merge: structuredClone // structuredClone throws on prototype-polluted objects const safeCopy = structuredClone(incoming)
structuredClone is the cleanest option when you need a deep copy and don't need custom merge logic. It uses the structured clone algorithm, which explicitly does not copy prototype chains or symbolic properties, and it throws on objects that cannot be safely cloned.
Detecting existing pollution
Add a startup check to detect if your process has already been polluted — useful in multi-tenant environments where a previous request may have poisoned the runtime:
function checkForPrototypePollution(): void {
const probe = {}
if ('isAdmin' in probe || 'bypass' in probe || 'override' in probe) {
process.stderr.write('[SECURITY] Object.prototype pollution detected — aborting\n')
process.exit(1)
}
// Check constructor chain too
if (({} as any).constructor !== Object) {
process.stderr.write('[SECURITY] Constructor prototype pollution detected — aborting\n')
process.exit(1)
}
}
SkillAudit grading for prototype pollution
| Finding | Severity | Grade impact |
|---|---|---|
Recursive merge with for...in and no __proto__ key guard | High | −12 |
hasOwnProperty guard not using Object.prototype.hasOwnProperty.call | Medium | −5 |
| Deep clone via custom recursive copy (vs structuredClone) | Low | −3 |
Config maps use Object.create(null) | — | +3 |
| All merges use safe libraries (deepmerge 4+) or structuredClone | — | +4 |
Scan your MCP server for prototype pollution risks
SkillAudit's static scanner looks for recursive merge patterns, for...in loops over external input, and direct property assignment from JSON-parsed objects. Paste your GitHub URL and get a full report with line numbers in under 60 seconds.