DoS Prevention · Request Limits · Rate Limiting
MCP server payload size DoS security
MCP tool handlers that accept string arguments — file content, document text, SQL queries, template bodies — rarely enforce an upper bound on the argument size. An LLM agent given a task that involves large documents, or an attacker who can influence tool call arguments, can send megabyte-scale payloads that exhaust the Node.js heap, saturate the CPU with JSON parsing, or trigger gigabyte-scale memory expansion through a zip bomb. All three classes of payload attack are preventable with limits applied before the argument reaches any tool handler code.
Attack class 1: raw size exhaustion
The simplest attack sends a single tool call with an oversized string argument. JSON.stringify and JSON.parse are synchronous — a 100 MB JSON body blocks the Node.js event loop for several seconds while parsing. A 1 GB body can exhaust the V8 heap and crash the process:
// No size limit — the handler will JSON.parse the entire body before validation
server.tool('process_document', async (req) => {
const { content } = req.params; // content could be 500 MB
return analyzeDocument(content);
});
// The fix: enforce size at the transport layer before JSON.parse
// For HTTP-based MCP servers (Express/Fastify):
app.use(express.json({ limit: '1mb' }));
// For WebSocket-based MCP servers:
const MAX_MESSAGE_SIZE = 1 * 1024 * 1024; // 1 MB
wss.on('connection', (ws) => {
ws.on('message', (data) => {
if (data.length > MAX_MESSAGE_SIZE) {
ws.send(JSON.stringify({ error: 'Message too large' }));
ws.close(1009, 'Message too large');
return;
}
handleMessage(JSON.parse(data.toString()));
});
});
Attack class 2: deeply nested JSON (JSON bomb)
A deeply nested JSON object can cause O(n²) or exponential memory usage during parsing, even if the serialized byte size is small. The classic "JSON bomb" is a deeply nested array that expands to a much larger in-memory structure. Node.js JSON.parse does not have a recursion depth limit:
// JSON bomb — small serialized size, exponential memory expansion during parse
// {"a":{"a":{"a":{"a": ... (10,000 levels deep) ...}}}}
// V8's stack limit will eventually throw, but only after significant CPU use
// Defense: check nesting depth before or during parsing
// Option A: reject strings with excessive depth markers before parse
function hasExcessiveNesting(rawJson, maxDepth = 20) {
let depth = 0;
let maxSeen = 0;
for (const ch of rawJson) {
if (ch === '{' || ch === '[') {
depth++;
if (depth > maxSeen) maxSeen = depth;
if (maxSeen > maxDepth) return true;
} else if (ch === '}' || ch === ']') {
depth--;
}
}
return false;
}
function safeParse(rawJson, maxDepth = 20) {
if (hasExcessiveNesting(rawJson, maxDepth)) {
throw new ToolError('INVALID_INPUT', 'JSON nesting too deep');
}
return JSON.parse(rawJson);
}
Attack class 3: archive expansion (zip bomb)
MCP servers with file upload or archive-processing tools (ZIP, tar, gzip) are vulnerable to zip bombs — archives that are small when compressed but expand to terabytes when extracted. A 42 KB zip bomb (42.zip) expands to 4.5 petabytes through recursive zip nesting; a simpler 1 MB compressed file can expand to several gigabytes if the handler extracts without checking the expansion ratio:
// VULNERABLE — no expansion size check
async function handleExtractArchive(req) {
const { archiveContent } = req.params;
const buffer = Buffer.from(archiveContent, 'base64');
const extracted = await unzip(buffer); // could expand to GB/TB
return { files: extracted };
}
// SAFE — check expansion ratio and total extracted size during extraction
import { unzipSync, Unzip } from 'fflate';
const MAX_COMPRESSED_SIZE = 10 * 1024 * 1024; // 10 MB max input
const MAX_EXTRACTED_SIZE = 50 * 1024 * 1024; // 50 MB max total extracted
const MAX_EXPANSION_RATIO = 100; // max 100:1 compression ratio
async function safeExtract(archiveBase64) {
const buffer = Buffer.from(archiveBase64, 'base64');
if (buffer.length > MAX_COMPRESSED_SIZE) {
throw new ToolError('INVALID_INPUT', 'Archive too large');
}
let totalExtracted = 0;
const files = {};
// Stream extraction with size tracking
await new Promise((resolve, reject) => {
const unzip = new Unzip((stream) => {
stream.ondata = (err, chunk, final) => {
if (err) return reject(err);
totalExtracted += chunk.length;
if (totalExtracted > MAX_EXTRACTED_SIZE) {
reject(new ToolError('INVALID_INPUT', 'Archive expansion exceeds limit'));
return;
}
if (totalExtracted / buffer.length > MAX_EXPANSION_RATIO) {
reject(new ToolError('INVALID_INPUT', 'Suspicious compression ratio'));
return;
}
if (final) files[stream.name] = Buffer.concat([files[stream.name] ?? Buffer.alloc(0), chunk]);
else files[stream.name] = Buffer.concat([files[stream.name] ?? Buffer.alloc(0), chunk]);
};
stream.start();
});
unzip.push(buffer, true);
resolve(files);
});
return files;
}
Schema-level size limits with Zod
Transport-level size limits protect against raw byte floods. Schema-level limits protect against logical oversize inputs that pass the transport limit but would still exhaust resources during processing:
import { z } from 'zod';
const DocumentSchema = z.object({
content: z.string()
.max(100_000) // 100 KB max content string
.min(1),
format: z.enum(['text', 'markdown', 'json']),
metadata: z.record(z.string().max(512))
.optional()
.refine(m => !m || Object.keys(m).length <= 20, 'Too many metadata keys'),
});
const BatchSchema = z.object({
items: z.array(
z.object({ id: z.string().max(128), value: z.string().max(1024) })
).max(100), // max 100 items per batch call
});
// Both together: transport limit (1 MB) + schema limit (100 KB content, 100 batch items)
// Even a valid 1 MB JSON body with 1000 items will fail the batch schema limit
SkillAudit findings for payload size
Related: Rate limiting deep dive · Input validation patterns · Resilience and fail-secure design