Security Reference
MCP server JSON Schema security: ajv, Zod, and TypeBox validation gotchas
JSON Schema validation is the first layer of MCP tool input defense. But validation libraries have their own security surface: unsafe defaults, coercion bypasses, union type confusion, and prototype pollution vectors. Getting this layer wrong means the validation passes while still delivering adversarial input to your tool handler.
ajv: the most common vulnerabilities
ajv is the most widely used JSON Schema validator in the Node.js ecosystem and the one most commonly found in MCP server dependencies. Several of its features create security gotchas:
1. allOf/anyOf union type confusion
// SCHEMA: accepts either a string OR a number
const schema = {
anyOf: [
{ type: 'string', maxLength: 100 },
{ type: 'number', minimum: 0, maximum: 1000 }
]
};
// With coercion enabled (the default in ajv-formats), "100" coerces to 100
// It passes the number branch. But the handler expects typeof === 'string' downstream
const ajv = new Ajv({ coerceTypes: true }); // DANGEROUS for security validation
const validate = ajv.compile(schema);
validate("100"); // true — coerces to 100, passes number branch
// Handler receives 100 (number) — type mismatch that bypasses string-specific sanitization
// SAFE: disable coercion for security-sensitive schemas
const ajv = new Ajv({ coerceTypes: false, useDefaults: false });
// Now "100" fails the number branch (not a number) and passes string branch as string
2. $ref injection risk
// VULNERABLE: schema loaded from untrusted source includes $ref
// An attacker-controlled schema can reference file:// paths or
// internal endpoints to cause SSRF during schema compilation
// Safe approach: compile schemas from trusted sources only, at startup
// Never compile schemas from user-supplied data
// If you must accept user schemas:
const ajv = new Ajv({
loadSchema: async (uri) => {
// Block all $ref resolution — deny by default
throw new Error(`$ref resolution disabled: ${uri}`);
}
});
// Or strip $ref at input before compilation
function stripRefs(schema) {
if (typeof schema !== 'object' || schema === null) return schema;
const cleaned = Object.fromEntries(
Object.entries(schema)
.filter(([k]) => k !== '$ref' && k !== '$schema')
.map(([k, v]) => [k, Array.isArray(v) ? v.map(stripRefs) : stripRefs(v)])
);
return cleaned;
}
3. additionalProperties and prototype pollution
// SCHEMA without additionalProperties: false
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
action: { type: 'string' }
}
// Missing: additionalProperties: false
};
// Attacker input:
const input = {
name: "legit",
action: "read",
"__proto__": { "isAdmin": true }, // ajv passes this through without additionalProperties: false
"constructor": { "prototype": { "isAdmin": true } }
};
// After validation, Object.assign(config, input) pollutes Object.prototype
// config.isAdmin === true for all objects subsequently created
// SAFE: always set additionalProperties: false on object schemas
const safeSchema = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 100 },
action: { type: 'string', enum: ['read', 'write', 'list'] }
},
required: ['name', 'action'],
additionalProperties: false // strips any extra properties including __proto__
};
Zod: coercion and union pitfalls
import { z } from 'zod';
// GOTCHA 1: z.coerce.number() accepts strings that look like numbers
const dangerousSchema = z.coerce.number();
dangerousSchema.parse("Infinity"); // → Infinity (passes!)
dangerousSchema.parse("1e308"); // → Infinity (passes!)
dangerousSchema.parse("-0"); // → -0 (passes! -0 === 0 is false in Object.is)
// SAFE: use z.number() with explicit bounds, no coercion
const safeNumber = z.number()
.finite() // blocks Infinity, -Infinity, NaN
.int() // if integer expected
.min(0).max(10_000); // explicit bounds
// GOTCHA 2: z.string().url() passes javascript: and data: URLs
z.string().url().parse("javascript:alert(1)"); // PASSES in Zod v3
z.string().url().parse("data:text/html,"); // PASSES
// SAFE: validate URL scheme after z.string().url()
const safeUrl = z.string().url().refine(
url => { try { const u = new URL(url); return ['https:', 'http:'].includes(u.protocol); } catch { return false; } },
{ message: 'URL must use http or https scheme' }
);
// GOTCHA 3: z.union() tries branches in order — first match wins
// With coercion, "1" can match z.number() before z.string()
const ambiguous = z.union([z.number(), z.string()]);
// Prefer discriminated unions when the type is known at schema design time
const discriminated = z.discriminatedUnion('type', [
z.object({ type: z.literal('read'), path: z.string() }),
z.object({ type: z.literal('write'), path: z.string(), content: z.string() })
]);
TypeBox: strict mode requirements
import { Type, TypeCompiler } from '@sinclair/typebox';
// GOTCHA: TypeBox's default compiled validator allows additional properties
// Same as ajv without additionalProperties: false
// UNSAFE: extra properties pass through
const UnsafeSchema = Type.Object({
repo: Type.String(),
action: Type.String()
});
// { repo: "x", action: "y", __proto__: { isAdmin: true } } passes validation
// SAFE: use Type.Strict() to enforce additionalProperties: false
import { Type } from '@sinclair/typebox';
const SafeSchema = Type.Strict(Type.Object({
repo: Type.String({ maxLength: 500 }),
action: Type.Union([Type.Literal('audit'), Type.Literal('scan')])
}));
// Or add additionalProperties: false explicitly
const SafeSchemaManual = Type.Object(
{ repo: Type.String({ maxLength: 500 }) },
{ additionalProperties: false }
);
const validate = TypeCompiler.Compile(SafeSchema);
if (!validate.Check(input)) {
throw new McpError(ErrorCode.InvalidParams, [...validate.Errors(input)][0]?.message);
}
Schema version vulnerabilities
// JSON Schema draft-04 vs draft-07 vs 2019-09 vs 2020-12 have different semantics
// The 'type' keyword in draft-04 accepts integers for 'integer' type
// but 1.0 (a float that equals an integer) passes 'integer' type in draft-04
// and fails in draft-07+ (correctly — 1.0 is not an integer type in 2019-09)
// Most MCP SDKs use ajv with JSON Schema draft-07 by default
// Explicitly set the meta-schema to avoid ambiguity:
const ajv = new Ajv({
schemaId: '$id',
// Compile with the draft version you expect
});
// Or use Zod — no schema version to track, behavior is library-version-locked
Key principle: JSON Schema validation confirms shape and type. It does not confirm semantic safety. A path that passes type: string can still be ../../etc/passwd. A URL that passes format: uri can still resolve to 169.254.169.254. Always apply semantic validation (path traversal checks, URL scheme + IP range checks) after schema validation. See the input validation patterns guide for the full four-layer approach.
SkillAudit grading criteria
| Finding | Severity | Score impact |
|---|---|---|
| ajv compiled with coerceTypes: true on security-sensitive schemas | HIGH | −15 |
| $ref resolution enabled with external URI support | HIGH | −18 |
| Object schemas without additionalProperties: false | MEDIUM | −10 |
| z.string().url() without scheme allowlist | MEDIUM | −8 |
| z.coerce.* without Infinity/NaN guards | MEDIUM | −8 |
| additionalProperties: false on all object schemas | PASS | +5 |
| Semantic validation after schema validation | PASS | +5 |
| $ref resolution disabled or allowlisted | PASS | +5 |
Related SkillAudit checks
- Input validation patterns — schema validation is layer 1; the other 3 layers address semantic and policy concerns
- Prototype pollution security — additionalProperties gap is one of several prototype pollution vectors
- SSRF security — $ref resolution in ajv can cause SSRF during schema compilation
- Top 10 MCP security mistakes — schema-only validation without semantic checks is mistake #3