Claude skill JSON schema validation bypass
Claude skills that describe tool arguments with a JSON schema but validate them only at the TypeScript type level have no runtime defense against malformed, injected, or boundary-violating inputs. TypeScript types are erased at compile time; the actual runtime object received by a tool handler is whatever JSON the MCP client serialized and sent. The four patterns in SA-SKL-STR cover the specific ways this gap enables attacks: unchecked arbitrary input, prototype pollution through additionalProperties, type confusion through union discriminant gaps, and coercion attacks through permissive type unions.
MCP tool definitions include an inputSchema field that describes the expected shape of tool arguments as a JSON Schema object. This schema is used by LLM clients to generate valid tool calls and by documentation systems to describe the tool’s interface. What the schema does not do automatically is validate the incoming arguments at runtime on the server side. A skill author who defines inputSchema and then casts the parsed arguments to a TypeScript interface is trusting that whoever calls the tool will respect the schema — a trust that is warranted for cooperative LLM clients but fails under adversarial conditions including prompt injection, rogue MCP clients, and direct API calls from testing scripts or attackers who have obtained the skill’s endpoint. Runtime schema validation with a library like Zod, Joi, or AJV closes this gap by checking the actual runtime shape of the arguments before any handler logic runs.
SA-SKL-STR-001: TypeScript-only validation accepts arbitrary runtime input
TypeScript’s type system operates entirely at compile time. When a MCP tool handler is compiled to JavaScript, all type annotations are stripped. The arguments object received at runtime is a plain JavaScript object deserialized from JSON, and its actual shape depends entirely on what the caller sent. A skill that declares const { userId, action }: { userId: string; action: 'read' | 'write' | 'delete' } = args does not verify at runtime that action is one of the three allowed values. An attacker who calls the tool with action: "../../../etc/passwd" or action: "admin" will have that value passed directly to whatever downstream logic processes it. This is the foundational problem that makes all subsequent STR patterns possible: the schema is declared but not enforced.
// VULNERABLE: TypeScript types only — no runtime validation
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 'file-tool', version: '1.0.0' });
server.tool(
'read_file',
{
path: { type: 'string', description: 'File path to read' },
encoding: { type: 'string', enum: ['utf8', 'base64', 'hex'] }
},
async (args) => {
// TypeScript sees this as { path: string; encoding: 'utf8'|'base64'|'hex' }
// At RUNTIME, args is a plain object — encoding could be anything
const { path, encoding } = args as { path: string; encoding: string };
// Attacker sends: encoding = "utf8; rm -rf /" (if used in shell later)
// Attacker sends: path = "../../../../etc/shadow"
// No validation fires — handler proceeds with whatever was sent
const content = await fs.readFile(path, encoding as BufferEncoding);
return { content: [{ type: 'text', text: content }] };
}
);
// SECURE: runtime schema validation with Zod
import { z } from 'zod';
const ReadFileSchema = z.object({
path: z.string()
.regex(/^[a-zA-Z0-9._/-]+$/, 'path must be alphanumeric with safe separators')
.max(500),
encoding: z.enum(['utf8', 'base64', 'hex'])
});
server.tool(
'read_file',
{
path: { type: 'string', description: 'File path to read' },
encoding: { type: 'string', enum: ['utf8', 'base64', 'hex'] }
},
async (args) => {
// Throws ZodError with descriptive message if args don't match schema
// MCP SDK converts the throw to an error tool result — LLM sees the validation message
const validated = ReadFileSchema.parse(args);
const content = await fs.readFile(validated.path, validated.encoding);
return { content: [{ type: 'text', text: content }] };
}
);
// DETECTION (SkillAudit):
// Tool handler that destructures or casts args without a preceding .parse() / .validate() call
const noRuntimeValidation = searchAST(
'tool handler body accessing args.* or (args as T) without ' +
'z.object.parse | Joi.object.validate | ajv.validate call'
);
if (noRuntimeValidation.length > 0) → CRITICAL // SA-SKL-STR-001
The inputSchema MCP field is for the LLM client, not your server: the JSON Schema in inputSchema tells the LLM how to form a valid call. It does not trigger any server-side validation in the MCP SDK. Your handler receives the raw deserialized object regardless of whether it matches the declared schema. Every handler that processes tool arguments must validate them with a runtime schema library before accessing any field.
args.* without a preceding .parse() call → CRITICAL.SA-SKL-STR-002: additionalProperties: true enables prototype pollution via __proto__
JSON Schema’s additionalProperties: true (the default when the keyword is omitted) allows the schema to accept objects with any keys beyond those explicitly declared. When combined with the absence of runtime validation, this allows an attacker to include a __proto__ key in the tool arguments object. If that object is then passed to a function that merges it with another object using Object.assign(target, args) or a recursive merge like _.merge(target, args), the __proto__ key pollutes the prototype of Object itself, affecting all subsequently created plain objects in the process. In a skill context, prototype pollution can escalate to privilege: if any configuration object later checks config.isAdmin and that property is not set, JavaScript resolves it from the prototype chain and finds the attacker-injected true value.
// VULNERABLE: additionalProperties not restricted + object merge with args
server.tool(
'update_settings',
{
settings: {
type: 'object',
properties: {
theme: { type: 'string' },
language: { type: 'string' }
}
// additionalProperties: true (implicit default) — __proto__ key accepted
}
},
async (args) => {
const userSettings = {};
// Attacker sends: { "settings": { "__proto__": { "isAdmin": true } } }
// After this merge, ALL plain objects have .isAdmin === true
Object.assign(userSettings, args.settings);
// Later in any part of the process:
const config = {};
if (config.isAdmin) { // ← now true due to prototype pollution
grantAdminAccess();
}
}
);
// SECURE: explicit additionalProperties false + deep clone instead of assign
const UpdateSettingsSchema = z.object({
settings: z.object({
theme: z.string().optional(),
language: z.string().optional()
}).strict() // .strict() rejects unrecognized keys — equivalent to additionalProperties: false
});
server.tool(
'update_settings',
{ settings: { type: 'object', additionalProperties: false,
properties: { theme: { type: 'string' }, language: { type: 'string' } } } },
async (args) => {
const validated = UpdateSettingsSchema.parse(args);
// validated.settings only has known keys; __proto__ injection rejected by .strict()
// Use structuredClone for deep copy instead of Object.assign to prevent any residual pollution
const userSettings = structuredClone(validated.settings);
return { content: [{ type: 'text', text: JSON.stringify(userSettings) }] };
}
);
// Also: avoid lodash _.merge / jQuery.extend deep with user-controlled objects entirely
// Use JSON.parse(JSON.stringify(src)) or structuredClone() for deep copy of validated data
// DETECTION (SkillAudit):
// Object.assign(target, args.*) or _.merge(target, args.*) without Zod .strict() / additionalProperties: false
const protoPollu = searchAST(
'Object.assign(*, args.*) | _.merge(*, args.*) | jquery.extend(true, *, args.*) ' +
'without preceding schema that sets additionalProperties:false or z.object.strict()'
);
if (protoPollu.length > 0) → HIGH // SA-SKL-STR-002
Prototype pollution affects the entire process, not just one request: once Object.prototype.isAdmin is set to true by a polluting merge, every subsequent const x = {}; if (x.isAdmin) check in the process returns true. This state is not cleaned up between tool calls. In a long-running MCP server process that handles multiple user sessions, a single prototype pollution attack from one user’s tool call can grant every subsequent user elevated privileges until the process is restarted.
additionalProperties: true with object merge: __proto__ key in tool arguments pollutes Object prototype via Object.assign or lodash merge; all subsequent plain objects in process inherit attacker-controlled properties. Detection: Object.assign/_.merge with args.* without additionalProperties: false → HIGH.SA-SKL-STR-003: union discriminant without exhaustive check enables type confusion
Many MCP tools accept a discriminated union argument: a type field that determines which branch of logic to execute, similar to a tagged union or algebraic data type. A TypeScript switch on a union discriminant with a default case that throws a compile-time never check guarantees exhaustiveness at compile time, but this guarantee does not apply at runtime when the input is not validated. If a handler switches on args.type without validating it against an enum first, a value outside the known set reaches the default case and either throws an unhandled exception or — if the default case is a catch-all — silently executes the wrong branch. More subtly, if the handler checks if (args.type === 'admin_query') as a privilege gate, an attacker who sends type: 'Admin_Query' (wrong case) bypasses the check and falls through to a less-privileged branch that may still execute the privileged operation via a shared utility function.
// VULNERABLE: switch on unvalidated discriminant — type confusion at runtime
server.tool(
'execute_query',
{
type: { type: 'string', enum: ['select', 'insert', 'admin'] },
query: { type: 'string' }
},
async (args) => {
// TypeScript types this as 'select'|'insert'|'admin' but args is unvalidated at runtime
switch (args.type) {
case 'select':
return executeSelect(args.query);
case 'insert':
return executeInsert(args.query);
case 'admin':
checkAdminAuth();
return executeAdmin(args.query);
default:
// Attacker sends type: 'SELECT' or type: 'ADMIN' or type: 'admin\x00'
// Falls here — depending on default, may silently do nothing
// or miss the admin auth check in a refactored code path
throw new Error('unknown type');
}
}
);
// SECURE: validate enum first, then switch on the validated value
const QuerySchema = z.object({
type: z.enum(['select', 'insert', 'admin']), // exact case, exact values only
query: z.string().max(10_000)
});
server.tool(
'execute_query',
{
type: { type: 'string', enum: ['select', 'insert', 'admin'] },
query: { type: 'string' }
},
async (args) => {
// ZodError thrown before switch if type is not exactly 'select', 'insert', or 'admin'
const validated = QuerySchema.parse(args);
switch (validated.type) {
case 'select':
return executeSelect(validated.query);
case 'insert':
return executeInsert(validated.query);
case 'admin':
checkAdminAuth();
return executeAdmin(validated.query);
// TypeScript exhaustiveness check: next line won't compile if a case is missing
default:
const _exhaustive: never = validated.type;
throw new Error(`Unhandled type: ${_exhaustive}`);
}
}
);
// DETECTION (SkillAudit):
// switch(args.type) or if (args.type === ...) without preceding z.enum().parse()
const unionConfusion = searchAST(
'switch(args.*) | if(args.* === *) without preceding z.enum().parse(args.*)'
);
if (unionConfusion.length > 0) → HIGH // SA-SKL-STR-003
Use z.enum not z.string for discriminant fields: z.string() accepts any string value; z.enum(['select', 'insert', 'admin']) rejects anything outside the declared set at parse time. For discriminated union schemas, use Zod’s z.discriminatedUnion('type', [...]) to automatically select the correct schema branch based on the discriminant — this gives you both the enum check and the per-branch field validation in one step.
switch(args.*) or if(args.* ===) without z.enum().parse() → HIGH.SA-SKL-STR-004: permissive type union enables coercion attacks
JSON Schema allows a field to accept multiple types via an anyOf or type: ['string', 'number'] declaration. When a tool handler receives such a field and passes it to a downstream function that treats the value differently based on its runtime type, an attacker can supply the unexpected type to trigger unintended behavior. The canonical case is a numeric ID field declared as accepting either string or number: a handler that passes the value directly to a SQL query built with template literals treats the numeric 1 OR 1=1 (which cannot be represented as a single number but a string representation exploits string path) differently than an integer, while a function that converts the value with parseInt(id) produces 1 from both "1 OR 1=1" (where parseInt stops at the space) and 1.5 where truncation produces 1. The broader pattern is: whenever a field accepts multiple types, the handler must explicitly check the type after validation and treat each case explicitly rather than relying on implicit JavaScript coercion.
// VULNERABLE: permissive type union — coercion produces unexpected behavior
server.tool(
'get_record',
{
id: {
anyOf: [{ type: 'string' }, { type: 'number' }],
description: 'Record ID (string or numeric)'
}
},
async (args) => {
const { id } = args;
// Handler uses template literal — works for numbers, vulnerable if id is string
// Attacker sends: id = "1; DROP TABLE records; --"
// parseInt("1; DROP TABLE records; --") === 1 (stops at semicolon) — hides injection
// But if id is used directly in a non-parameterized path...
const record = await db.query(`SELECT * FROM records WHERE id = ${id}`);
return { content: [{ type: 'text', text: JSON.stringify(record) }] };
}
);
// SECURE: strict type, parameterized query
const GetRecordSchema = z.object({
id: z.union([
z.string().regex(/^\d+$/).transform(Number), // only numeric strings → convert to number
z.number().int().positive()
])
// Both branches produce a validated positive integer — no string injection path
});
server.tool(
'get_record',
{ id: { type: 'integer', minimum: 1 } },
async (args) => {
const validated = GetRecordSchema.parse(args);
// validated.id is guaranteed to be a positive integer
// Always use parameterized queries — never template literals with any user value
const record = await db.query('SELECT * FROM records WHERE id = ?', [validated.id]);
return { content: [{ type: 'text', text: JSON.stringify(record) }] };
}
);
// Additional coercion risk: boolean-like fields
// { type: ['boolean', 'string'] } where handler uses truthiness:
// !!args.isAdmin === true when isAdmin = "false" (non-empty string is truthy)
// Fix: z.boolean() — rejects the string "false" entirely
// DETECTION (SkillAudit):
// anyOf / type array in inputSchema without strict per-branch transform in Zod or explicit type check
const coercionRisk = searchAST(
'inputSchema field with anyOf | type:array ' +
'without z.union([z.string().regex, z.number()]) or explicit if(typeof args.* === ...)'
);
if (coercionRisk.length > 0) → MEDIUM // SA-SKL-STR-004
Prefer the most specific type over anyOf: if your tool really needs to accept both strings and numbers for the same semantic field, convert to a canonical type at the schema layer using .transform() — a Zod z.coerce.number() or a regex-gated z.string().regex(/^\d+$/).transform(Number). The handler body should never see the raw union; it should see a typed, validated, canonical value. If the field truly only ever receives numbers from cooperative LLM clients, declare it as z.number().int().positive() and reject anything else.
anyOf [string, number] field passed to template literal SQL or truthy check; unexpected type enables SQL injection through parseInt truncation or auth bypass via truthy non-empty string. Detection: anyOf/type array field without explicit per-branch Zod transform → MEDIUM.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| STR-001: TypeScript-only validation | CRITICAL | Add ZodSchema.parse(args) before accessing any field in every tool handler |
| STR-002: additionalProperties prototype pollution | HIGH | Use z.object({...}).strict(); avoid Object.assign/_.merge with user input |
| STR-003: union discriminant type confusion | HIGH | Use z.enum([...]) for discriminant fields; add TypeScript never exhaustiveness check |
| STR-004: coercion attacks via permissive unions | MEDIUM | Narrow to most specific type; use .transform() at schema layer to canonical type |
SkillAudit’s static analysis checks all MCP tool handlers for runtime schema validation using its AST-based pattern matcher. The SA-SKL-STR family is one of 15 attack-surface families covered in our complete attack surface reference. Related families: SA-SKL-STR input sanitization bypass, SA-SKL-SRL deserialization, SA-SKL-CLI argument injection.
Scan your Claude skill for STR validation gaps
SkillAudit checks for missing runtime validation, prototype pollution vectors, and union type confusion across all 6 security axes in 60 seconds.
Get your free audit