Introduction
MCP servers receive tool arguments from AI models that are influenced by user prompts—prompts that may be crafted maliciously. Every tool input is a potential injection vector. Robust input validation using schema-based parsing is the primary defense against SQL injection, path traversal, SSRF, and other injection attacks in MCP servers.
Key Concepts
- Schema-Based Validation: Using Zod (or similar) to define exact input shapes, types, and constraints before any processing occurs.
- Path Traversal: An attack where inputs like
../../etc/passwdescape the intended directory. Defense requires resolving to absolute paths and checking against an allowed root. - SQL Injection: Injecting SQL commands through tool arguments. Defense requires parameterized queries—never string concatenation.
- SSRF (Server-Side Request Forgery): Tricking the server into making requests to internal services. Defense requires URL allowlisting and blocking private IP ranges.
- Content-Type Validation: Ensuring binary resources match their declared MIME type to prevent polyglot file attacks.
Real World Context
An MCP server provides a read_file tool for an AI coding assistant. A user crafts a prompt that causes the AI to request read_file({ path: "../../../../etc/shadow" }). Without path validation, the server reads the system's password hashes. With proper validation, the path resolves outside the allowed root and the request is rejected with a clear error.
Deep Dive
Zod Schema Validation
Define strict schemas for every tool. Zod's .parse() throws on invalid input, preventing malformed data from reaching business logic:
typescriptimport { z } from "zod"; const ReadFileInput = z.object({ path: z.string() .min(1) .max(500) .regex(/^[a-zA-Z0-9_\-\.\/]+$/, "Invalid characters in path"), encoding: z.enum(["utf-8", "base64"]).default("utf-8") }); const QueryInput = z.object({ table: z.enum(["users", "orders", "products"]), columns: z.array(z.string().regex(/^[a-z_]+$/)).min(1).max(20), limit: z.number().int().min(1).max(1000).default(100), offset: z.number().int().min(0).default(0) });
The regex on path characters blocks null bytes, shell metacharacters, and Unicode tricks. The table allowlist prevents access to system tables.
Secure Path Validation
Always resolve paths and verify they remain within the allowed root:
typescriptimport path from "path"; import { stat } from "fs/promises"; const ALLOWED_ROOT = "/data/files"; async function validatePath(userPath: string): Promise<string> { // Resolve to absolute, collapsing .. and symlinks const resolved = path.resolve(ALLOWED_ROOT, userPath); // Check the resolved path starts with allowed root if (!resolved.startsWith(ALLOWED_ROOT + path.sep) && resolved !== ALLOWED_ROOT) { throw new Error("Path traversal detected: access denied"); } // Check for symlink escape const realPath = await fs.realpath(resolved); if (!realPath.startsWith(ALLOWED_ROOT + path.sep) && realPath !== ALLOWED_ROOT) { throw new Error("Symlink escape detected: access denied"); } return realPath; } server.tool("read_file", "Read a file from the data directory", { path: z.string(), encoding: z.enum(["utf-8", "base64"]).optional() }, async (args) => { const input = ReadFileInput.parse(args); const safePath = await validatePath(input.path); const content = await fs.readFile(safePath, input.encoding); return { content: [{ type: "text", text: content }] }; } );
The double check (resolve + realpath) catches both .. sequences and symlink-based escapes.
SQL Injection Prevention
Always use parameterized queries. Never interpolate user input into SQL strings:
typescript// DANGEROUS: SQL injection server.tool("search", "Search records", { query: z.string() }, async ({ query }) => { // Attacker sends: "'; DROP TABLE users; --" const rows = await db.query(`SELECT * FROM records WHERE name = '${query}'`); return { content: [{ type: "text", text: JSON.stringify(rows) }] }; } ); // SAFE: Parameterized query server.tool("search", "Search records", { query: z.string().max(200), table: z.enum(["records", "logs"]) }, async (args) => { const { query, table } = SearchInput.parse(args); const rows = await db.query( `SELECT id, name, created_at FROM ${table} WHERE name = $1 LIMIT 100`, [query] ); return { content: [{ type: "text", text: JSON.stringify(rows) }] }; } );
Note that table comes from an enum allowlist, so it is safe to interpolate. The query value uses a parameterized placeholder $1.
URL Validation for HTTP Tools
Prevent SSRF by validating URLs against an allowlist and blocking private IP ranges:
typescriptimport { URL } from "url"; import dns from "dns/promises"; import { isPrivate } from "ip"; const ALLOWED_HOSTS = ["api.github.com", "registry.npmjs.org"]; async function validateUrl(input: string): Promise<URL> { const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) { throw new Error(`Host not allowed: ${url.hostname}`); } if (url.protocol !== "https:") { throw new Error("Only HTTPS URLs are allowed"); } // Resolve DNS and check for private IPs (anti-SSRF) const addresses = await dns.resolve4(url.hostname); for (const addr of addresses) { if (isPrivate(addr)) { throw new Error("Resolved to private IP range: blocked"); } } return url; }
This defense ensures the server never makes requests to internal services, even if DNS is manipulated.
Common Pitfalls
- Validating after use: Always validate inputs before any processing. Parsing after a database call is useless—the damage is done.
- Relying only on regex: Regex can filter characters, but path validation requires resolve + startsWith checks. Regex alone misses encoded sequences and edge cases.
- Trusting
Content-Typeheaders: When processing uploaded binary resources, verify the actual file content (magic bytes) rather than trusting the declared MIME type.
Best Practices
- Parse, don't validate: Use Zod's
.parse()to transform input into a typed, validated object. This guarantees downstream code works with clean data. - Fail closed: If validation fails, reject the request entirely. Never fall back to lenient parsing.
- Log validation failures: Record failed validation attempts with sanitized details for security monitoring without leaking sensitive data.
Summary
- Use Zod schemas to define exact shapes and constraints for all tool inputs
- Path traversal defense requires
path.resolve()+startsWith()+realpath()checks against the allowed root - SQL injection is prevented by parameterized queries and table name allowlists
- SSRF protection requires URL allowlisting, protocol restrictions, and DNS resolution checks for private IPs
- Always validate before processing and fail closed on invalid input
Code Examples
import path from "path";
import fs from "fs/promises";
const ALLOWED_ROOT = "/data/files";
async function validatePath(userPath: string): Promise<string> {
const resolved = path.resolve(ALLOWED_ROOT, userPath);
if (!resolved.startsWith(ALLOWED_ROOT + path.sep) && resolved !== ALLOWED_ROOT) {
throw new Error("Path traversal detected");
}
const realPath = await fs.realpath(resolved);
if (!realPath.startsWith(ALLOWED_ROOT + path.sep) && realPath !== ALLOWED_ROOT) {
throw new Error("Symlink escape detected");
}
return realPath;
}