MCP Server Security — Runtime Type Coercion
MCP server runtime type coercion security — JavaScript == exploits, prototype pollution via JSON.parse, and comparison bypass in authorization checks
JavaScript's type coercion rules — 0 == false, [] == false, null == undefined, array-to-string coercion — create security vulnerabilities when LLM-generated tool arguments flow into authorization checks without strict type validation. MCP tool handlers receive arguments as JSON-deserialized objects: a field the code assumes is a string might be a number, an array, or an object. Authorization checks written with == instead of ===, or truthiness checks on values that should be strictly boolean, are exploitable via type confusion. JSON.parse also creates prototype pollution risk when the parsed payload contains __proto__ keys. This page covers each class with the specific bypass and the fix.
Attack 1: == comparison bypass in authorization checks — 0, [], and "" are all "falsy"
JavaScript's abstract equality operator == performs type coercion before comparing. This creates bypasses in authorization code that checks role strings, user IDs, or permission levels with ==. When tool arguments are JSON-deserialized, a field that should be a string might arrive as a number or array if the LLM generates unexpected argument types. Authorization checks using == can be bypassed by type confusion: 0 == false, "" == false, [] == false, [0] == false all evaluate to true. A check like if (args.permission == "write") will not catch an attacker sending permission: ["write"] — but ["write"] == "write" is true only when the array has exactly one element due to toString coercion, making it a subtle bypass vector.
// DANGEROUS: type coercion exploits in authorization checks
// 1. == comparison with role string — array bypass
function hasWritePermission(role) {
return role == 'write'; // WRONG: ["write"] == "write" is true
}
hasWritePermission(['write']); // → true! array coerces to "write"
// 2. Truthiness check on user ID — 0 is falsy
function requireUserId(userId) {
if (!userId) throw new Error('Not authenticated'); // WRONG: !0 is true
}
requireUserId(0); // passes silently — userId 0 is treated as unauthenticated
// 3. Permission level comparison — numeric coercion
function hasMinLevel(userLevel, requiredLevel) {
return userLevel >= requiredLevel; // WRONG if userLevel is a string
}
hasMinLevel('999admin', 10); // '999admin' >= 10 → '999admin' coerces to NaN → false
hasMinLevel('10', 9); // '10' >= 9 → '10' coerces to 10 → true (unexpected)
// -----------------------------------------------------------------------
// SAFE: strict equality and type guards before authorization checks
import { z } from 'zod';
const WRITE_ROLES = new Set(['write', 'admin', 'owner']);
// Zod schema enforces type before reaching auth logic
const ToolArgsSchema = z.object({
role: z.string(), // z.string() rejects arrays, numbers
userId: z.string().uuid(), // UUID string only — not 0, not null
accessLevel: z.number().int().min(0).max(100),
});
function authorizeToolCall(rawArgs) {
const args = ToolArgsSchema.parse(rawArgs); // throws on wrong types
// Now safe to use strict equality — types are guaranteed by schema
if (!WRITE_ROLES.has(args.role)) { // Set.has uses ===
throw new Error(`Insufficient role: ${args.role}`);
}
if (typeof args.userId !== 'string' || args.userId.length === 0) {
throw new Error('Invalid userId');
}
return true;
}
Attack 2: Prototype pollution via JSON.parse — __proto__ and constructor gadgets
JavaScript's JSON.parse will process a payload like {"__proto__":{"isAdmin":true}} without error in many environments. The behavior depends on the JavaScript engine: in V8 (Node.js), JSON.parse('{"__proto__":{"isAdmin":true}}') produces a plain object with a __proto__ property that is set as a data property, not as the prototype chain — so standard JSON.parse is not directly exploitable for prototype pollution via this path. However, functions that deep-merge or recursively assign parsed JSON into existing objects (lodash _.merge, Object.assign on nested objects, custom deep-copy utilities) will follow the __proto__ key and mutate Object.prototype. MCP tool handlers that merge received arguments into session state using recursive merge are vulnerable.
// DANGEROUS: recursive merge with prototype pollution via __proto__
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]); // WRONG: follows __proto__
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends: {"__proto__": {"isAdmin": true}} as tool arguments
const userConfig = {};
deepMerge(userConfig, JSON.parse('{"__proto__":{"isAdmin":true}}'));
// Now Object.prototype.isAdmin === true — every object appears to be admin
const req = {};
console.log(req.isAdmin); // → true! Prototype polluted
// -----------------------------------------------------------------------
// SAFE: key allowlist in merge, null-prototype objects, Object.hasOwn check
function safeMerge(target, source, allowedKeys) {
for (const key of Object.keys(source)) {
// Reject prototype-chain-modifying keys
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
throw new Error(`Prototype pollution attempt: key=${key}`);
}
// Only merge known keys if allowlist provided
if (allowedKeys && !allowedKeys.has(key)) continue;
if (
typeof source[key] === 'object' &&
source[key] !== null &&
!Array.isArray(source[key]) &&
Object.hasOwn(source, key)
) {
if (!Object.hasOwn(target, key)) target[key] = Object.create(null);
safeMerge(target[key], source[key], null); // recurse, still with key check
} else {
target[key] = source[key];
}
}
return target;
}
// Use null-prototype objects as containers for parsed config
// Properties of null-prototype objects don't inherit from Object.prototype
function parseToolArgs(jsonString) {
const parsed = JSON.parse(jsonString);
// Validate no dangerous keys recursively
function checkKeys(obj, path = '') {
if (typeof obj !== 'object' || obj === null) return;
for (const key of Object.keys(obj)) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
throw new Error(`Rejected: dangerous key "${path}${key}" in tool arguments`);
}
checkKeys(obj[key], `${path}${key}.`);
}
}
checkKeys(parsed);
return parsed;
}
Attack 3: Array-to-string coercion bypass — single-element arrays match string checks
When JavaScript coerces an array to a string for comparison, it calls Array.prototype.toString(), which joins elements with commas: ['admin'].toString() === 'admin' and ['admin', 'user'].toString() === 'admin,user'. A role check that uses string comparison without first asserting the input is a string will accept ['admin'] wherever 'admin' is expected. MCP tool arguments that should be scalar strings (role names, permission labels, plan identifiers) but arrive as single-element arrays will pass string equality checks while having a different type — which can cause downstream logic (switch statements, Set.has, database queries) to behave incorrectly.
// DANGEROUS: string comparison without type check — array bypass
const PREMIUM_PLANS = ['pro', 'team', 'enterprise'];
function isPremiumUser(plan) {
return PREMIUM_PLANS.includes(plan); // WRONG: includes uses ===, but...
}
// This is safe actually for includes — but what about:
function getFeatureLimit(plan) {
if (plan == 'free') return 3; // WRONG: == lets ['free'] match
if (plan == 'pro') return 100;
return 0; // default: no access
}
getFeatureLimit(['free']); // → 3! Array bypasses to free tier check
// -----------------------------------------------------------------------
// SAFE: strict type guard before any authorization check
function assertString(value, name) {
if (typeof value !== 'string') {
throw new TypeError(`Expected string for ${name}, got ${Array.isArray(value) ? 'array' : typeof value}`);
}
return value;
}
// In Zod schema for all tool arguments:
const FeatureCheckSchema = z.object({
plan: z.string(), // rejects arrays — z.string() uses typeof check
userId: z.string().uuid(),
featureName: z.enum(['audit', 'ci-webhook', 'sso', 'policy-export']),
});
// Manual check for legacy code:
function getFeatureLimitSafe(rawPlan) {
const plan = assertString(rawPlan, 'plan');
const LIMITS = { free: 3, pro: 100, team: 500, enterprise: Infinity };
return LIMITS[plan] ?? 0; // unknown plan gets 0 — fail closed
}
Attack 4: null prototype objects and hasOwnProperty bypass
Objects created via Object.create(null) or delivered as the result of certain deserialization operations lack the hasOwnProperty method. Code that calls obj.hasOwnProperty('key') directly on such an object throws a TypeError. When the error is caught silently or the property check is skipped due to the exception, authorization logic that depended on that check is bypassed. Similarly, using the in operator on objects with polluted prototypes checks inherited properties in addition to own properties — 'isAdmin' in req returns true if Object.prototype.isAdmin was set by a prototype pollution attack.
// DANGEROUS: hasOwnProperty on potentially null-prototype object
function hasPermission(permissionsObj, permName) {
// TypeError if permissionsObj has no prototype (Object.create(null))
if (permissionsObj.hasOwnProperty(permName)) { // WRONG
return permissionsObj[permName] === true;
}
return false;
}
// DANGEROUS: 'in' operator checks prototype chain
function isAdmin(req) {
return 'isAdmin' in req; // WRONG: true if Object.prototype.isAdmin exists
}
// -----------------------------------------------------------------------
// SAFE: Object.hasOwn (ES2022, Node 16.9+) — works on null-prototype objects,
// checks own properties only (not prototype chain)
function hasPermissionSafe(permissionsObj, permName) {
if (
typeof permissionsObj !== 'object' ||
permissionsObj === null ||
Array.isArray(permissionsObj)
) {
return false; // not a permissions object at all
}
// Object.hasOwn works even when hasOwnProperty is not available on the object
if (!Object.hasOwn(permissionsObj, permName)) return false;
// Strict check: permission value must be exactly true, not truthy
return permissionsObj[permName] === true;
}
function isAdminSafe(user) {
// Check own property only, not inherited properties
return Object.hasOwn(user, 'isAdmin') && user.isAdmin === true;
}
// When parsing config or permissions from JSON, freeze the result
// to prevent later modification by prototype-polluting code
function parsePermissions(jsonString) {
const raw = JSON.parse(jsonString);
// Only allow known keys in permissions objects
const ALLOWED_PERMISSION_KEYS = new Set(['read', 'write', 'admin', 'delete']);
const permissions = Object.create(null); // null prototype — no inherited methods
for (const [key, value] of Object.entries(raw)) {
if (!ALLOWED_PERMISSION_KEYS.has(key)) continue;
if (typeof value !== 'boolean') continue; // only boolean permission values
permissions[key] = value;
}
return Object.freeze(permissions); // immutable after creation
}
SkillAudit findings
The following findings appear in SkillAudit audit reports for MCP servers with type coercion vulnerabilities in authorization code:
CRITICAL Authorization check uses == instead of === on role/permission values from tool arguments. Role or permission comparisons in tool handler authorization code use abstract equality (==), which allows single-element arrays matching string roles via toString coercion. A manipulated LLM can pass ["admin"] where "admin" is expected, passing the authorization check.
CRITICAL Deep merge of JSON tool arguments without __proto__ key guard — prototype pollution possible. The tool handler merges received JSON arguments into an existing object using a recursive merge utility. The merge does not skip __proto__, constructor, or prototype keys. A payload containing {"__proto__":{"isAdmin":true}} can pollute Object.prototype and cause every subsequent object to inherit isAdmin: true.
HIGH No type assertion before string authorization checks — array-to-string coercion bypass. Tool argument values used in role/plan authorization checks are not validated as strings before comparison. Arrays coerce to strings in a way that can match string literals, bypassing checks that are not preceded by a typeof === 'string' guard.
HIGH in operator used for property existence check — polluted prototype grants false positives. Permission checks use 'permission' in obj to verify that a permission key is present. This checks the prototype chain in addition to own properties. A prototype pollution attack that sets Object.prototype.write = true causes all permission checks using the in operator to return true. Use Object.hasOwn(obj, key).
MEDIUM Tool argument schema uses z.coerce.string() — silently converts arrays and numbers to strings. Zod schema uses z.coerce.string() for fields that should strictly be strings. This coerces arrays and numbers without throwing, allowing type confusion to pass schema validation undetected. Use z.string() (strict, no coercion) for security-relevant string fields.
Paste a GitHub URL at skillaudit.dev to get a graded report card.