← Blog

Claude skill JSON serialization security

MCP skills process JSON at two distinct boundaries: parsing untrusted JSON from tool arguments (controlled by the LLM or attacker-influenced context) and serializing LLM-constructed objects back to JSON for logging, caching, or inter-process communication. Both directions introduce security vulnerabilities that are specific to JavaScript’s object model and V8’s serialization behavior. SA-SKL-SER covers four patterns: prototype pollution via __proto__ injection that grants permissions across the entire process, circular reference DoS that crashes JSON.stringify on LLM-constructed tool results, Date.parse() NaN propagation that bypasses database range checks, and Buffer.from() encoding confusion that corrupts data or throws unexpectedly. Each pattern exploits a quirk of JavaScript’s type system that is invisible to type annotations and Zod schemas.

By SkillAudit  |  3 Nov 2026

JavaScript’s serialization model differs from strongly-typed languages in ways that create security-relevant edge cases. JSON.parse() produces plain objects whose prototype chain is Object.prototype, which means certain key names modify the prototype rather than the instance. JSON.stringify() throws synchronously on circular references, which is a process-terminating event in an unhandled path. Date.parse() silently returns NaN for invalid dates, and NaN propagates through arithmetic in ways that can bypass numeric range checks. Buffer.from()’s second parameter accepts an encoding name that, if attacker-controlled, can cause unexpected behavior for unfamiliar encoding names. Each of these is a potential injection point when the JSON being processed originates from LLM-synthesized tool arguments.

SA-SKL-SER-001: JSON.parse() of payload with __proto__ key triggers prototype pollution affecting all requests

JavaScript’s JSON.parse() processes the key __proto__ literally and assigns the parsed value to the __proto__ property of the resulting object. In V8 (Node.js), assigning to obj.__proto__ modifies the prototype of the object, not a property named __proto__. If the parsed object is then merged into another object (via Object.assign(), spread syntax, or a deep merge utility), the __proto__ properties are inherited by Object.prototype, affecting every subsequent object in the process. A payload of {"__proto__": {"isAdmin": true}} causes any subsequent check if (user.isAdmin) to return true for all users, because Object.prototype.isAdmin is now true and the property lookup falls through to the prototype. In an MCP skill, this can grant admin access to all subsequent requests in the same Node.js process, not just the attacker’s own session.

// VULNERABLE: JSON.parse() result merged into config or user object
import express from 'express';

app.post('/configure', express.json(), (req, res) => {
  // req.body was produced by JSON.parse() inside express.json()
  // BUG: merging parsed input into a base object with Object.assign
  // Attacker sends: { "__proto__": { "isAdmin": true } }
  const config = Object.assign({}, defaultConfig, req.body);
  // ↑ Object.assign copies __proto__ as a property assignment
  // In V8: config.__proto__ = { isAdmin: true }
  // Modifies Object.prototype.isAdmin → true for ALL objects in process

  saveConfig(config);
  res.json({ ok: true });
});

// Later, in a completely different request handler:
app.get('/admin', (req, res) => {
  const user = getUserFromSession(req);
  // BUG: user.isAdmin is true for ALL users (inherited from Object.prototype)
  if (!user.isAdmin) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // Admin panel now accessible to everyone
  res.json({ adminData: getAdminData() });
});

// ALSO VULNERABLE: deep merge utilities that traverse __proto__
// lodash <4.17.11, merge-deep, deepmerge, jquery extend with deep=true
// These are known vulnerable — always use patched versions

// SECURE: sanitize parsed JSON before merging; use Object.create(null) for dicts
import { z } from 'zod';

const configSchema = z.object({
  theme: z.enum(['light', 'dark']),
  maxResults: z.number().int().min(1).max(100),
  // No unknown keys — z.object() by default strips unknown keys
});

app.post('/configure', express.json(), (req, res) => {
  // Zod parse: validates shape, strips unknown keys (including __proto__)
  // parse() throws if input doesn't match schema — __proto__ is unknown, stripped
  const config = configSchema.parse(req.body);

  // Even after Zod parse, if you need a raw dict for unknown keys, use Object.create(null)
  // Objects created with null prototype have no __proto__ chain to pollute
  const safeDict = Object.create(null);
  Object.assign(safeDict, config);  // now safe — null-prototype base

  saveConfig(safeDict);
  res.json({ ok: true });
});

// For JSON parsing of unknown structure (e.g., proxied external API responses),
// use JSON.parse with a reviver that rejects dangerous keys:
function safeJsonParse(text: string): unknown {
  return JSON.parse(text, (key, value) => {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      return undefined;  // drop the key entirely
    }
    return value;
  });
}

// DETECTION (SkillAudit):
// Object.assign({}, req.body | JSON.parse(*)) without Zod schema validation
// OR deep merge utility calls on untrusted input
const protoPollution = searchAST(
  'Object.assign(*, JSON.parse(*) | req.body) WITHOUT schema validation'
);
if (protoPollution) → CRITICAL  // SA-SKL-SER-001

Prototype pollution is process-wide, not request-scoped: once Object.prototype is modified, every subsequent object in the Node.js process inherits the polluted properties. The attack does not require the attacker to maintain a session or make multiple requests. A single POST with {"__proto__": {"isAdmin": true}} permanently elevates all users in that process until it restarts. Validate all parsed JSON with a strict schema (Zod’s .parse() strips unknown keys including __proto__) before merging into any object.

CRITICAL
SA-SKL-SER-001 — Prototype pollution: Object.assign({}, JSON.parse(input)) or deep merge of parsed JSON without __proto__ key rejection; attacker sends {"__proto__": {"isAdmin": true}}; Object.prototype.isAdmin becomes true process-wide; all subsequent permission checks return elevated privileges for all users. Detection: Object.assign or spread of req.body/JSON.parse result without Zod validation → CRITICAL.

SA-SKL-SER-002: JSON.stringify() on LLM-constructed circular reference object throws unhandled TypeError

JSON.stringify() throws a synchronous TypeError: Converting circular structure to JSON when the object graph contains a cycle. LLM tool results can contain circular structures because the LLM may return objects assembled from multiple tool calls whose results reference each other, or because the skill’s own tool result building code inadvertently creates a cycle (e.g., adding a parent reference back to a child). In an MCP skill that serializes tool results for caching, logging, or IPC without a try/catch around JSON.stringify(), a circular reference causes an unhandled rejection that crashes the request handler or, in some Node.js configurations, crashes the entire process. The fix is a try/catch around all JSON.stringify() calls on untrusted objects, or using a serializer that handles circular references gracefully (e.g., replacing cycles with [Circular]).

// VULNERABLE: JSON.stringify without circular reference protection
import express from 'express';

app.post('/cache-result', express.json(), async (req, res) => {
  const toolResult = req.body.result;  // LLM-constructed tool result object

  // BUG: if toolResult has circular references, this throws synchronously
  // Unhandled TypeError crashes the request handler
  // With unhandledRejection, may crash the process
  const serialized = JSON.stringify(toolResult);
  await redis.set(`result:${req.body.id}`, serialized);

  res.json({ cached: true });
});

// Example circular structure from LLM tool output:
const parentNode = { id: 1, name: 'parent', children: [] };
const childNode = { id: 2, name: 'child', parent: parentNode };
parentNode.children.push(childNode);
// JSON.stringify(parentNode) → TypeError: Converting circular structure to JSON

// SECURE: try/catch + circular reference handling
// Option A: try/catch with sanitization
app.post('/cache-result', express.json(), async (req, res) => {
  const toolResult = req.body.result;

  let serialized: string;
  try {
    serialized = JSON.stringify(toolResult);
  } catch (err) {
    if (err instanceof TypeError && err.message.includes('circular')) {
      // Replace circular references and retry serialization
      serialized = JSON.stringify(toolResult, getCircularReplacer());
    } else {
      throw err;  // re-throw non-circular errors
    }
  }

  await redis.set(`result:${req.body.id}`, serialized);
  res.json({ cached: true });
});

// Circular replacer function — replaces cycles with '[Circular]' string
function getCircularReplacer() {
  const seen = new WeakSet();
  return (_key: string, value: unknown) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) {
        return '[Circular]';
      }
      seen.add(value);
    }
    return value;
  };
}

// Option B: use a library that handles circulars (safe-stable-stringify, flatted)
import { stringify } from 'flatted';  // handles circular refs natively

const serialized = stringify(toolResult);  // never throws on circulars

// Option C: Zod schema validation on toolResult before serialization
// (Zod parse traverses the object and would fail fast on unexpected types,
//  though it doesn't specifically protect against circulars — use A or B for that)

// DETECTION (SkillAudit):
// JSON.stringify(req.body.* | toolResult | llmResult) WITHOUT try/catch
const unsafeStringify = searchAST(
  'JSON.stringify(req.body.* | toolResult | llmOutput) WITHOUT try { } catch'
);
if (unsafeStringify) → HIGH  // SA-SKL-SER-002

LLM tool results frequently contain circular structures: the LLM assembles tool result objects from multiple sources in its context. If the skill’s tool functions return objects with back-references (e.g., a DOM-like tree where each node has a parent pointer), those cycles end up in the tool result. Wrap all JSON.stringify() calls on LLM-originated data in a try/catch, and prefer flatted or a circular replacer for logging and caching paths where you need the full structure.

HIGH
SA-SKL-SER-002 — Circular reference DoS: JSON.stringify(toolResult) without try/catch; LLM-constructed tool result contains circular object reference; JSON.stringify throws synchronous TypeError; unhandled error crashes request handler or process. Detection: JSON.stringify on LLM-originated object without try/catch → HIGH.

SA-SKL-SER-003: Date.parse() on user-supplied string propagates NaN through timestamp arithmetic

Date.parse() and new Date(string) return NaN (as a timestamp number) when the input string is not a valid date. NaN is an IEEE 754 special value that propagates through all arithmetic operations: NaN + 86400000 is NaN, NaN < someNumber is false, NaN > someNumber is also false. In an MCP skill that validates date ranges for API access control — e.g., checking whether an audit result has expired, whether a subscription is still active, or whether a timestamp argument is within the allowed range — an attacker who passes an invalid date string causes the range check to silently fail. Specifically, if (expiresAt > Date.now()) evaluates to false when expiresAt is NaN, meaning an expired-at-NaN credential appears expired (the check returns false even if the intent is “is it still valid?”) — or, if the polarity is reversed, it appears permanently valid. The fix is to explicitly check isNaN() immediately after parsing and reject NaN timestamps before any comparison.

// VULNERABLE: Date.parse() result used in comparison without isNaN check
app.get('/audit/:id', authenticateMiddleware, async (req, res) => {
  const audit = await db.get('SELECT * FROM audits WHERE id = ?', [req.params.id]);

  // Timestamps from user-supplied query params
  const afterParam = req.query.after;   // "2026-01-01" or "not-a-date" or "" etc.

  // BUG: Date.parse("not-a-date") = NaN
  // NaN > anyNumber = false → filter silently passes all audits (no lower bound)
  // NaN < anyNumber = false → upper bound check also silently fails
  const afterTs = Date.parse(afterParam);

  if (audit.createdAt < afterTs) {
    return res.status(404).json({ error: 'Not found' });
  }

  res.json({ audit });
});

// ALSO VULNERABLE: access expiry check
async function isSubscriptionValid(userId: string): Promise<boolean> {
  const sub = await db.get('SELECT expires_at FROM subscriptions WHERE user_id = ?', [userId]);
  const expiresAt = new Date(sub.expires_at).getTime();
  // BUG: if expires_at is malformed in the DB (empty string, NULL cast to string, etc.)
  // expiresAt = NaN
  // NaN > Date.now() = false → subscription appears expired even if it should be valid
  // OR if polarity is reversed:
  // Date.now() > NaN = false → subscription appears forever valid
  return expiresAt > Date.now();
}

// SECURE: validate timestamp before use with isNaN check
function parseTimestamp(value: unknown, fieldName: string): number {
  if (typeof value !== 'string' && typeof value !== 'number') {
    throw new RangeError(`${fieldName}: expected string or number, got ${typeof value}`);
  }

  const ts = typeof value === 'number' ? value : Date.parse(value as string);

  if (isNaN(ts)) {
    throw new RangeError(`${fieldName}: invalid date/timestamp value: ${String(value)}`);
  }

  // Optional: validate range (reject year 0 or year 9999 timestamps)
  const MIN_TS = new Date('2020-01-01').getTime();
  const MAX_TS = new Date('2030-12-31').getTime();
  if (ts < MIN_TS || ts > MAX_TS) {
    throw new RangeError(`${fieldName}: timestamp out of allowed range`);
  }

  return ts;
}

// Usage:
app.get('/audit/:id', authenticateMiddleware, async (req, res) => {
  const audit = await db.get('SELECT * FROM audits WHERE id = ?', [req.params.id]);

  try {
    // Throws RangeError if afterParam is not a valid date string
    const afterTs = parseTimestamp(req.query.after, 'after');

    if (audit.createdAt < afterTs) {
      return res.status(404).json({ error: 'Not found' });
    }
  } catch (err) {
    if (err instanceof RangeError) {
      return res.status(400).json({ error: err.message });
    }
    throw err;
  }

  res.json({ audit });
});

// For access expiry, always validate at insertion AND at retrieval:
async function isSubscriptionValid(userId: string): Promise<boolean> {
  const sub = await db.get('SELECT expires_at FROM subscriptions WHERE user_id = ?', [userId]);

  // Defensive: validate DB value before comparison (DB may have been manually edited)
  let expiresAt: number;
  try {
    expiresAt = parseTimestamp(sub.expires_at, 'expires_at');
  } catch {
    // Malformed expiry in DB — treat as expired (fail closed)
    return false;
  }

  return expiresAt > Date.now();
}

// DETECTION (SkillAudit):
// Date.parse(req.*) or new Date(req.*) result used in comparison WITHOUT isNaN() check
const nanPropagation = searchAST(
  '(Date.parse(req.*) | new Date(req.*).getTime()) USED IN comparison WITHOUT isNaN check'
);
if (nanPropagation) → HIGH  // SA-SKL-SER-003

NaN comparisons always return false: NaN > x, NaN < x, and NaN === x all return false for any value of x, including NaN. This means an expiry check like expiresAt > Date.now() silently returns false when expiresAt is NaN, making the subscription appear expired. The opposite check Date.now() > expiresAt also returns false, making it appear not yet expired. Always use isNaN() to validate before any comparison and fail closed (treat invalid dates as expired / out of range).

HIGH
SA-SKL-SER-003 — Date.parse NaN propagation: Date.parse(req.query.*) or new Date(dbValue).getTime() result used in timestamp comparison without isNaN() check; invalid date string returns NaN; all NaN comparisons return false; range check silently passes all records or causes access control bypass depending on polarity. Detection: Date.parse(req.*) used in </> comparison without isNaN() → HIGH.

SA-SKL-SER-004: Buffer.from() with untrusted encoding parameter causes data corruption or throws

Buffer.from(string, encoding) accepts an encoding name as its second parameter. If this encoding parameter is attacker-controlled, the behavior depends on the encoding value: unsupported encoding names throw a TypeError (crashing the handler), 'hex' with non-hex characters silently drops malformed byte pairs (corrupting data), and 'latin1' or 'binary' on UTF-8 input misinterprets multi-byte sequences (corrupting data). In MCP skills that accept a fileEncoding or encoding argument alongside file content, passing this argument directly to Buffer.from() without validating it against a known-safe list creates a vector for both DoS (throw on unsupported encoding) and data corruption (silent data loss for hex/latin1 on UTF-8). The fix is to validate the encoding against an explicit allowlist before passing it to Buffer.from().

// VULNERABLE: encoding parameter passed directly to Buffer.from
import { z } from 'zod';

const processFileTool = {
  name: 'process_file',
  inputSchema: z.object({
    fileContent: z.string(),
    encoding: z.string(),  // BUG: accepts any string — not validated against Buffer encodings
  }),

  async execute(args: { fileContent: string; encoding: string }) {
    // BUG: args.encoding is attacker-controlled
    // Passing 'invalid-encoding' → TypeError: Unknown encoding: invalid-encoding
    //   → Unhandled throw crashes the handler
    // Passing 'hex' with non-hex content → Buffer.from silently drops malformed bytes
    //   → File content silently corrupted, no error, audit results wrong
    // Passing 'latin1' on UTF-8 content → multi-byte chars misinterpreted
    //   → Accented characters, CJK, emoji silently corrupted
    const buffer = Buffer.from(args.fileContent, args.encoding as BufferEncoding);
    return processBuffer(buffer);
  }
};

// ALSO VULNERABLE: file encoding from HTTP headers
app.post('/decode', async (req, res) => {
  const encoding = req.headers['x-file-encoding'];  // attacker-controlled header
  // BUG: header value used directly as Buffer encoding
  const buffer = Buffer.from(req.body.content, encoding as BufferEncoding);
  res.json({ decoded: processBuffer(buffer) });
});

// SECURE: validate encoding against explicit allowlist
const SAFE_BUFFER_ENCODINGS = new Set([
  'utf8', 'utf-8',    // standard Unicode
  'base64',           // base64-encoded binary
  'base64url',        // URL-safe base64
  'hex',              // only safe when content is validated as valid hex first
  'ascii',            // 7-bit ASCII subset
  // Intentionally excluded: 'latin1'/'binary' (silent UTF-8 corruption),
  //   'ucs2'/'ucs-2'/'utf16le' (endianness-sensitive, rarely needed)
]);

const processFileTool = {
  name: 'process_file',
  inputSchema: z.object({
    fileContent: z.string(),
    encoding: z.enum(['utf8', 'utf-8', 'base64', 'base64url', 'hex', 'ascii']),
    // z.enum enforces the allowlist at schema validation time
  }),

  async execute(args: { fileContent: string; encoding: string }) {
    // Encoding already validated by Zod .enum — safe to pass to Buffer.from
    // But add belt-and-suspenders check in case schema is bypassed
    if (!SAFE_BUFFER_ENCODINGS.has(args.encoding)) {
      throw new TypeError(`Unsupported encoding: ${args.encoding}`);
    }

    // For 'hex' encoding: validate that content is valid hex before decode
    // (Buffer.from silently drops malformed hex pairs rather than throwing)
    if (args.encoding === 'hex') {
      if (!/^[0-9a-fA-F]*$/.test(args.fileContent) || args.fileContent.length % 2 !== 0) {
        throw new TypeError('Invalid hex string: must be even-length hex characters only');
      }
    }

    const buffer = Buffer.from(args.fileContent, args.encoding as BufferEncoding);
    return processBuffer(buffer);
  }
};

// DETECTION (SkillAudit):
// Buffer.from(args.*, args.encoding | req.headers.*) where encoding is not validated
const unsafeEncoding = searchAST(
  'Buffer.from(*, args.encoding | req.headers.*encoding* | req.body.*encoding*) ' +
  'WITHOUT encoding allowlist check'
);
if (unsafeEncoding) → MEDIUM  // SA-SKL-SER-004

'hex' silently drops malformed input: Buffer.from('ZZhello', 'hex') returns an empty Buffer — the ZZ is not valid hex, so Buffer.from silently produces no bytes. This silent failure can corrupt audit results without any error being thrown. If you accept hex-encoded content, validate that the string contains only [0-9a-fA-F] characters and has even length before passing it to Buffer.from. Similarly, 'latin1' treats each byte as a Latin-1 character, breaking multi-byte UTF-8 sequences — exclude it from the allowed encodings list for text processing.

MEDIUM
SA-SKL-SER-004 — Untrusted Buffer encoding: Buffer.from(content, args.encoding) without encoding allowlist validation; unsupported encoding throws TypeError crashing handler; 'hex' with non-hex input silently corrupts data; 'latin1' on UTF-8 input misinterprets multi-byte chars. Detection: Buffer.from(*, args.encoding) without Zod .enum() or explicit allowlist check → MEDIUM.

Remediation summary

FindingSeverityFix
SER-001: Prototype pollutionCRITICALValidate all parsed JSON with Zod .parse() (strips __proto__); use JSON.parse(text, safeReviver) that drops __proto__/constructor/prototype keys; use Object.create(null) for raw dictionaries
SER-002: Circular reference DoSHIGHWrap JSON.stringify(toolResult) in try/catch; use circular replacer function or flatted library for logging/caching of LLM-constructed objects
SER-003: Date.parse NaN propagationHIGHCall isNaN() immediately after Date.parse()/new Date().getTime(); throw RangeError on NaN; fail closed (treat NaN expiry as expired) in access control checks
SER-004: Buffer encoding confusionMEDIUMValidate encoding against allowlist (z.enum(['utf8', 'base64', 'hex', 'ascii'])); for hex, validate character set and even length before decode; exclude latin1/binary from allowed set

SkillAudit detects all four SA-SKL-SER-001 through SER-004 JSON serialization security patterns through static analysis of JSON parsing, object merge, date handling, and Buffer encoding code in MCP skill implementations. For related input validation patterns, see SA-SKL-STR JSON schema validation bypass and SA-SKL input sanitization bypass patterns. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for JSON serialization vulnerabilities

SkillAudit detects prototype pollution, circular reference DoS, Date.parse NaN propagation, and Buffer encoding confusion across the full SA-SKL-SER family in 60 seconds.

Get your free audit