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

Scan your MCP server for prototype pollution, depth-unlimited JSON processing, and unbounded body parsing.

Run a free audit → How grading works →

See also