Topic: mcp server serialization security
MCP server serialization security — prototype pollution, JSON schema validation order, recursive depth limits
MCP server tool handlers receive JSON arguments from the AI model. That JSON — shaped by whatever the model was instructed to produce — can carry payloads designed to exploit the Node.js runtime directly: {"__proto__":{"isAdmin":true}} to poison Object.prototype, a 500-level nested object to cause a stack overflow in recursive processing, a 50 MB body to exhaust heap memory, or an adminOverride key injected into a config merge to silently enable privileged behavior. Each of these is a serialization-layer vulnerability that exists before any business logic runs. This page covers the full set of Node.js serialization security patterns an MCP server needs.
Prototype pollution via JSON.parse and Object.assign
JavaScript's JSON.parse will parse {"__proto__":{"isAdmin":true}} into a plain object that has an own property named __proto__. When this object is passed to Object.assign({}, parsed), the assignment to target["__proto__"] modifies the target object's prototype chain — setting isAdmin: true on Object.prototype itself. Every subsequent object in the same process inherits isAdmin: true.
// DANGEROUS: JSON.parse followed by Object.assign — classic prototype pollution vector
// Payload: {"__proto__":{"isAdmin":true}}
// After this runs, ({}).isAdmin === true for every object created afterwards
server.tool('updateSettings', settingsSchema, async ({ configJson }) => {
const userConfig = JSON.parse(configJson);
// Object.assign copies __proto__ as if it were a regular key assignment,
// which writes to Object.prototype — pollutes the prototype chain process-wide
const merged = Object.assign({}, defaultConfig, userConfig);
await applyConfig(merged);
return { content: [{ type: 'text', text: 'Settings updated.' }] };
});
// SAFE OPTION 1: structuredClone() — strips __proto__ during the structured clone algorithm
// structuredClone does not copy non-enumerable inherited properties, so __proto__ as
// an own property is copied as a regular string key, not as prototype assignment.
server.tool('updateSettings', settingsSchema, async ({ configJson }) => {
let userConfig: unknown;
try {
userConfig = JSON.parse(configJson);
} catch {
return { isError: true, content: [{ type: 'text', text: 'Invalid JSON.' }] };
}
if (typeof userConfig !== 'object' || userConfig === null || Array.isArray(userConfig)) {
return { isError: true, content: [{ type: 'text', text: 'Config must be a JSON object.' }] };
}
// structuredClone safely copies the object — no prototype chain manipulation
const safeConfig = structuredClone(userConfig);
// Whitelist extraction: only copy known, expected keys from safeConfig into defaults
const allowed = ['theme', 'language', 'pageSize'] as const;
const merged = { ...defaultConfig };
for (const key of allowed) {
if (key in safeConfig) {
(merged as any)[key] = (safeConfig as any)[key];
}
}
await applyConfig(merged);
return { content: [{ type: 'text', text: 'Settings updated.' }] };
});
// SAFE OPTION 2: Object.create(null) as the merge target
// Objects with null prototype have no __proto__ accessor — polluting them
// does not affect Object.prototype.
function safeMerge(defaults: Record<string, unknown>, overrides: Record<string, unknown>) {
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
const result = Object.create(null) as Record<string, unknown>;
for (const [k, v] of Object.entries(defaults)) result[k] = v;
for (const [k, v] of Object.entries(overrides)) {
if (!BLOCKED.has(k)) result[k] = v;
}
return result;
}
JSON schema validation order matters
Validating a JSON payload after parsing it means the payload has already been parsed into a JavaScript object by the time the validator sees it. A prototype pollution payload modifies Object.prototype during the JSON.parse + Object.assign step — before the validator even runs. Similarly, a ReDoS payload (a crafted string matched by a catastrophic regex in the schema validator) executes the denial-of-service before the validator has a chance to reject the input on other grounds. Validation must happen against the raw string or as the very first operation on the parsed object.
// DANGEROUS: parse first, validate second
// The prototype pollution payload runs during the merge step before ajv sees the object
import Ajv from 'ajv';
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 100 },
count: { type: 'integer', minimum: 1, maximum: 1000 },
},
additionalProperties: false,
};
server.tool('processData', rawSchema, async ({ payload }) => {
const parsed = JSON.parse(payload); // __proto__ pollution happens HERE
const merged = Object.assign({}, baseOptions, parsed); // already polluted
const valid = ajv.validate(schema, merged); // too late — pollution already applied
if (!valid) return { isError: true, content: [{ type: 'text', text: 'Invalid input.' }] };
return processWithOptions(merged);
});
// SAFE: validate BEFORE merging into any config object; use strict mode to block
// additional properties; set allErrors:false to fail fast without scanning the whole payload
import Ajv from 'ajv';
const ajv = new Ajv({
strict: true, // reject unknown keywords, disallow additional properties by default
allErrors: false, // fail on first error — prevents ReDoS via exhaustive pattern matching
useDefaults: true,
});
const validatePayload = ajv.compile({
type: 'object',
properties: {
name: { type: 'string', maxLength: 100 },
count: { type: 'integer', minimum: 1, maximum: 1000 },
},
additionalProperties: false,
required: ['name', 'count'],
});
server.tool('processData', rawSchema, async ({ payload }) => {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
return { isError: true, content: [{ type: 'text', text: 'Payload is not valid JSON.' }] };
}
// Validate BEFORE any merge, spread, or assignment
if (!validatePayload(parsed)) {
const firstError = validatePayload.errors?.[0];
return {
isError: true,
content: [{ type: 'text', text: `Validation failed: ${firstError?.message ?? 'unknown'}` }]
};
}
// At this point parsed has passed schema validation (no additionalProperties).
// Use structuredClone to copy without prototype chain manipulation.
const safe = structuredClone(parsed as { name: string; count: number });
// Merge ONLY validated, expected fields — never spread the whole parsed object
const options = { ...baseOptions, name: safe.name, count: safe.count };
return processWithOptions(options);
});
Recursive JSON depth limits against stack overflow
A payload like {"a":{"a":{"a":{"a": ... }}}} nested 10,000 levels deep will cause a stack overflow in any recursive function that walks the JSON tree. Node.js has a call stack limit of approximately 10,000–15,000 frames depending on frame size. A tool handler that recursively processes a JSON tree (e.g. to flatten it, compute a hash, or extract leaf nodes) will crash the handler process on a deeply nested payload, taking down all concurrent requests.
// DANGEROUS: recursive JSON tree walk with no depth limit
// A 10,000-level nested payload causes a RangeError: Maximum call stack size exceeded
// which crashes the tool handler (and all other in-flight handlers in the same process)
function extractLeaves(node: unknown): string[] {
if (typeof node !== 'object' || node === null) return [String(node)];
return Object.values(node as Record<string, unknown>).flatMap(extractLeaves); // RECURSIVE — no limit
}
server.tool('analyzeStructure', schema, async ({ data }) => {
const parsed = JSON.parse(data);
const leaves = extractLeaves(parsed); // CRASHES on deeply nested payload
return { content: [{ type: 'text', text: leaves.join(', ') }] };
});
// SAFE: iterative depth-first traversal with a hard depth limit
// Rejects the payload if it exceeds MAX_DEPTH before doing any processing
const MAX_DEPTH = 20;
const MAX_LEAVES = 10_000;
function checkDepth(node: unknown, maxDepth = MAX_DEPTH): void {
// Iterative BFS depth check — no recursion, no stack overflow risk
const queue: Array<{ val: unknown; depth: number }> = [{ val: node, depth: 0 }];
while (queue.length > 0) {
const { val, depth } = queue.shift()!;
if (depth > maxDepth) {
throw new RangeError(`JSON depth exceeds maximum of ${maxDepth}`);
}
if (val !== null && typeof val === 'object') {
for (const child of Object.values(val as Record<string, unknown>)) {
queue.push({ val: child, depth: depth + 1 });
}
}
}
}
function extractLeavesIterative(root: unknown): string[] {
const leaves: string[] = [];
const stack: unknown[] = [root];
while (stack.length > 0) {
if (leaves.length > MAX_LEAVES) throw new RangeError('Too many leaf nodes');
const node = stack.pop();
if (node === null || typeof node !== 'object') {
leaves.push(String(node));
} else {
stack.push(...Object.values(node as Record<string, unknown>));
}
}
return leaves;
}
server.tool('analyzeStructure', schema, async ({ data }) => {
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
return { isError: true, content: [{ type: 'text', text: 'Invalid JSON.' }] };
}
try {
checkDepth(parsed, MAX_DEPTH); // Reject before touching business logic
} catch (err: any) {
return { isError: true, content: [{ type: 'text', text: err.message }] };
}
const leaves = extractLeavesIterative(parsed);
return { content: [{ type: 'text', text: leaves.join(', ') }] };
});
Object property injection via config merges
When user-supplied JSON is merged into a server config object, the user controls which keys appear in the result. If the code later reads config.adminOverride, config.debug, or any other key from the merged object, the user can inject those keys. The risk is not prototype chain manipulation (that's a different pattern) but direct config key injection: the attacker adds keys the server code reads as if they were trusted server configuration.
// DANGEROUS: merging user-supplied JSON into server config object
// User can inject: adminOverride:true, debugMode:true, maxResults:999999, etc.
// Any key the downstream code reads from serverConfig is now user-controlled.
server.tool('query', querySchema, async ({ filters }) => {
const userFilters = JSON.parse(filters);
// INJECTION VECTOR: spreading user-controlled object into config
const config = { ...serverConfig, ...userFilters };
// If userFilters contains { adminOverride: true }, config.adminOverride is now true.
if (config.adminOverride) {
// Attacker reaches this branch
return adminQuery(config);
}
return regularQuery(config);
});
// SAFE: whitelist extraction — only copy explicitly allowed keys from user input
// Object.fromEntries(allowedKeys.map(...)) pattern: unknown keys are silently dropped
const ALLOWED_FILTER_KEYS = ['status', 'category', 'dateFrom', 'dateTo', 'pageSize'] as const;
type AllowedFilterKey = typeof ALLOWED_FILTER_KEYS[number];
function extractAllowedFilters(
src: Record<string, unknown>
): Partial<Record<AllowedFilterKey, unknown>> {
// Only the keys in ALLOWED_FILTER_KEYS are copied — all other keys are silently dropped.
// adminOverride, debugMode, __proto__, constructor, etc. are never extracted.
return Object.fromEntries(
ALLOWED_FILTER_KEYS
.filter(k => k in src)
.map(k => [k, src[k]])
) as Partial<Record<AllowedFilterKey, unknown>>;
}
server.tool('query', querySchema, async ({ filters }) => {
let rawFilters: unknown;
try {
rawFilters = JSON.parse(filters);
} catch {
return { isError: true, content: [{ type: 'text', text: 'Invalid filters JSON.' }] };
}
if (typeof rawFilters !== 'object' || rawFilters === null || Array.isArray(rawFilters)) {
return { isError: true, content: [{ type: 'text', text: 'Filters must be a JSON object.' }] };
}
// Only allowed filter keys make it into the query config
const safeFilters = extractAllowedFilters(rawFilters as Record<string, unknown>);
// serverConfig is never touched — query uses only validated, whitelisted inputs
return regularQuery({ ...serverConfig, filters: safeFilters });
});
Large array and string DoS via unbounded JSON.parse
An HTTP-transport MCP server that reads the request body and calls JSON.parse(body) without checking the body size first will attempt to parse an arbitrarily large payload. A 50 MB JSON array of strings allocates the full array plus each string's overhead in the V8 heap. Node.js default heap is 1.5–4 GB depending on platform, but parsing a 50 MB payload can consume 500 MB or more of heap due to intermediate representations. Multiple concurrent requests with large payloads can OOM the process.
// DANGEROUS: reading body and parsing without size limit
// A 50 MB payload is buffered into memory before JSON.parse even starts
import { createServer } from 'http';
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk); // No size check — buffers entire body in memory
}
const body = Buffer.concat(chunks).toString('utf8');
const data = JSON.parse(body); // OOM on large payload
handleMcpRequest(data, res);
}).listen(3000);
// SAFE: enforce MAX_BODY_SIZE before accumulating chunks;
// check Content-Length header before reading any body bytes.
import { createServer, IncomingMessage, ServerResponse } from 'http';
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB hard limit for MCP JSON-RPC requests
async function readBody(req: IncomingMessage): Promise<string> {
// Check Content-Length first — reject oversized requests before reading any bytes
const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
if (contentLength > MAX_BODY_BYTES) {
throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
}
let bytes = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
// Destroy the stream to free resources immediately
req.destroy();
throw Object.assign(new Error('Request body exceeded size limit'), { statusCode: 413 });
}
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
createServer(async (req: IncomingMessage, res: ServerResponse) => {
try {
const bodyText = await readBody(req); // Enforces size limit
let data: unknown;
try {
data = JSON.parse(bodyText); // Safe: body is already size-bounded
} catch {
res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
await handleMcpRequest(data, res);
} catch (err: any) {
const status = err.statusCode ?? 500;
res.writeHead(status).end(JSON.stringify({ error: err.message }));
}
}).listen(3000);
// For frameworks: Express / Fastify expose this as a built-in option:
// express().use(express.json({ limit: '1mb' }))
// fastify({ bodyLimit: 1_048_576 })
What SkillAudit checks in this area
Object.assign(dst, JSON.parse(userInput))— AST pattern match forObject.assignor object spread ({...x}) where the right operand is directly aJSON.parse()call or a variable known to hold a parsed JSON value from tool argument scope. Flagged HIGH for prototype pollution risk.- JSON.parse without depth check in recursive code — parsed JSON values passed to functions whose bodies contain recursive calls (self-referential call graphs detected via AST). Absence of a depth parameter or sentinel in the recursive function. Flagged HIGH for stack overflow DoS.
- Spread of user-supplied object into config — object spread expressions (
{ ...a, ...b }) orObject.assign(target, src)wheresrcoriginates from tool argument scope rather than validated, enumerated constants. Flagged HIGH for object injection. - No body size limit before JSON.parse — HTTP-transport servers where
JSON.parseis called on accumulated request body bytes without a preceding size check onContent-Lengthor a byte accumulation limit. Flagged WARN; present only for HTTP-transport servers, not stdio-transport.
Scan your MCP server for prototype pollution, depth-unlimited JSON processing, and unbounded body parsing.
Run a free audit → How grading works →See also
- MCP server prototype pollution — deep dive on
__proto__,constructor.prototype, and lodash merge vectors - MCP server input validation — Zod and JSON Schema patterns that stop injection before parsing
- MCP server deserialization security — the broader class of unsafe deserialization vulnerabilities
- MCP server regex DoS — ReDoS in JSON schema pattern validators
- MCP server mass assignment — object injection via ORM attribute whitelisting failures
- MCP server security checklist — comprehensive pre-publication hardening checklist