MCP Server Security — Multipart Form
MCP server multipart form security — boundary injection, path traversal in file uploads, MIME type spoofing, and DoS via large parts
MCP servers that accept file uploads via multipart/form-data — for analyzing code, parsing documents, or processing data files — face four classes of attack that are easy to overlook: multipart boundary injection in tool responses that embed attacker-controlled file content, path traversal via the Content-Disposition: filename parameter that writes files outside the intended upload directory, MIME type spoofing that causes the server to execute uploaded content as code, and DoS via oversized parts that exhaust memory when no per-part size limit is enforced. This page covers each with Node.js (Busboy, Formidable, Multer) patterns.
Attack 1: Multipart boundary injection in tool responses
An MCP tool that fetches external content (web pages, files, user-provided documents) and returns that content in a multipart response is vulnerable to boundary injection. If the fetched content contains the multipart boundary string that the server chose for its response, the response parser on the receiving end will interpret the injected boundary as a new multipart part boundary — splitting the response at the attacker-controlled injection point, potentially inserting additional "parts" with attacker-chosen Content-Disposition or Content-Type headers.
// DANGEROUS: fixed boundary string in multipart response + untrusted content
function buildMultipartResponse(res, parts) {
const boundary = 'MCP-BOUNDARY-12345'; // WRONG: fixed, guessable boundary
res.setHeader('Content-Type', `multipart/mixed; boundary="${boundary}"`);
for (const part of parts) {
// part.content may include the boundary string if it's user-fetched content
res.write(`--${boundary}\r\n`);
res.write(`Content-Type: ${part.type}\r\n\r\n`);
res.write(part.content); // WRONG: content could contain "--MCP-BOUNDARY-12345"
res.write('\r\n');
}
res.end(`--${boundary}--\r\n`);
}
// -----------------------------------------------------------------------
// SAFE: cryptographically random boundary + content sanitization
import { randomBytes } from 'crypto';
function buildMultipartResponseSafe(res, parts) {
// Random boundary: attacker cannot predict or inject it
const boundary = `MCP-${randomBytes(16).toString('hex')}`;
res.setHeader('Content-Type', `multipart/mixed; boundary="${boundary}"`);
for (const part of parts) {
// Verify content does not contain the boundary
if (typeof part.content === 'string' && part.content.includes(`--${boundary}`)) {
// Boundary collision (astronomically unlikely with 128-bit random): regenerate
// In practice: log and skip this part
logger.error('Boundary collision detected in multipart part', { partType: part.type });
continue;
}
// For binary content that may contain arbitrary bytes, use base64 encoding
const encoded = Buffer.isBuffer(part.content)
? part.content.toString('base64')
: Buffer.from(part.content, 'utf8').toString('base64');
res.write(`--${boundary}\r\n`);
res.write(`Content-Type: ${sanitizeContentType(part.type)}\r\n`);
res.write(`Content-Transfer-Encoding: base64\r\n\r\n`);
res.write(encoded);
res.write('\r\n');
}
res.end(`--${boundary}--\r\n`);
}
function sanitizeContentType(type) {
// Allowlist: only known safe content types for MCP tool responses
const ALLOWED = new Set(['text/plain', 'application/json', 'image/png', 'image/jpeg', 'application/pdf']);
if (!ALLOWED.has(type?.toLowerCase())) return 'application/octet-stream';
return type.toLowerCase();
}
Attack 2: Path traversal via Content-Disposition filename
The filename parameter in the Content-Disposition header is client-supplied and completely untrusted. An attacker who uploads a file with filename=../../../../etc/cron.d/backdoor or filename=../config.js will write their file outside the intended upload directory if the server uses the filename directly as the save path. Node.js multipart libraries (Busboy, Formidable) provide the raw filename value from the header without sanitization — the responsibility is entirely on the application code.
// DANGEROUS: raw filename from multipart header used as file path
import Busboy from 'busboy';
import fs from 'fs';
import path from 'path';
const UPLOAD_DIR = '/var/mcp/uploads';
app.post('/upload', (req, res) => {
const bb = Busboy({ headers: req.headers });
bb.on('file', (fieldname, fileStream, info) => {
const { filename } = info;
// WRONG: filename could be "../../../../etc/cron.d/backdoor"
const savePath = path.join(UPLOAD_DIR, filename);
fileStream.pipe(fs.createWriteStream(savePath)); // path traversal!
});
req.pipe(bb);
});
// -----------------------------------------------------------------------
// SAFE: UUID filename, original name stored in DB only
import { randomUUID } from 'crypto';
import { z } from 'zod';
const ALLOWED_EXTENSIONS = new Set(['.txt', '.json', '.csv', '.pdf', '.png', '.jpg', '.jpeg']);
function sanitizeFilename(rawFilename) {
// Extract only the basename — no directory components
const basename = path.basename(rawFilename ?? 'upload');
// Remove null bytes and control characters
const clean = basename.replace(/[\x00-\x1f\x7f]/g, '');
// Reject double extensions: "evil.php.jpg" → only last extension matters
const ext = path.extname(clean).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
throw new Error(`Unsupported file extension: ${ext}`);
}
return clean; // original name for display only
}
app.post('/upload', (req, res) => {
const bb = Busboy({
headers: req.headers,
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB hard limit per file
files: 5, // max 5 files per request
parts: 10, // max 10 parts total
},
});
bb.on('file', async (fieldname, fileStream, info) => {
try {
const originalName = sanitizeFilename(info.filename);
const ext = path.extname(originalName).toLowerCase();
// Generate UUID filename — original name never touches the filesystem
const savedName = `${randomUUID()}${ext}`;
const savePath = path.join(UPLOAD_DIR, savedName);
// Verify savePath is actually inside UPLOAD_DIR (defense-in-depth)
const resolvedSave = path.resolve(savePath);
const resolvedDir = path.resolve(UPLOAD_DIR);
if (!resolvedSave.startsWith(resolvedDir + path.sep)) {
throw new Error('Path traversal detected');
}
fileStream.pipe(fs.createWriteStream(resolvedSave));
// Store original name in DB for display
await db.run('INSERT INTO uploads (saved_name, original_name, session_id) VALUES (?, ?, ?)',
[savedName, originalName, req.session.id]);
} catch (err) {
fileStream.resume(); // consume remaining bytes to avoid hanging connection
logger.error('Upload rejected', { error: err.message });
}
});
req.pipe(bb);
});
Attack 3: MIME type spoofing — executing uploaded content
The Content-Type header in a multipart part is client-supplied. An attacker can upload a PHP script with Content-Type: image/jpeg and filename=backdoor.php. If the server trusts the client-supplied Content-Type for deciding how to handle the file (e.g., serving it back directly, or using it as the stored MIME type for later serving), the file may be executed by the web server or interpreted by downstream consumers. Validate file type by inspecting the first bytes of the file (magic number / file signature), not by reading the Content-Type header.
// DANGEROUS: trusting client-supplied Content-Type
bb.on('file', (fieldname, fileStream, info) => {
const mimeType = info.mimeType; // WRONG: client-controlled, completely untrusted
if (mimeType === 'image/jpeg') {
processAsImage(fileStream);
}
});
// -----------------------------------------------------------------------
// SAFE: magic number validation on actual file bytes
// File magic numbers (first bytes) for allowed types
const MAGIC_NUMBERS = {
'image/jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])],
'image/png': [Buffer.from([0x89, 0x50, 0x4E, 0x47])],
'application/pdf': [Buffer.from([0x25, 0x50, 0x44, 0x46])], // %PDF
'text/plain': null, // text files: no magic number, validate with charset detection
};
async function validateFileType(fileStream, allowedTypes) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
const HEADER_CHECK_BYTES = 8;
fileStream.on('data', (chunk) => {
chunks.push(chunk);
totalBytes += chunk.length;
if (totalBytes >= HEADER_CHECK_BYTES) {
const header = Buffer.concat(chunks).slice(0, HEADER_CHECK_BYTES);
// Check magic numbers
for (const [mimeType, signatures] of Object.entries(MAGIC_NUMBERS)) {
if (!allowedTypes.includes(mimeType) || !signatures) continue;
for (const sig of signatures) {
if (header.slice(0, sig.length).equals(sig)) {
resolve(mimeType);
return;
}
}
}
// No matching magic number for any allowed type
reject(new Error('File content does not match any allowed type'));
}
});
fileStream.on('end', () => {
if (totalBytes < HEADER_CHECK_BYTES) {
reject(new Error('File too small to validate'));
}
});
});
}
Attack 4: DoS via oversized multipart parts without size limits
Multipart parsers that stream file content into memory before writing to disk are vulnerable to memory exhaustion. Without a per-part fileSize limit, an attacker can upload an infinitely large file and consume all available server memory. Node.js's Busboy, Formidable, and Multer all require explicit size limits — they do not enforce defaults that protect against abuse. Additionally, the limits.files and limits.parts options prevent a multipart request with thousands of small parts from exhausting file descriptor or memory limits through part-count multiplication.
// DANGEROUS: no size limits — attacker uploads arbitrary large file
const bb = Busboy({ headers: req.headers }); // WRONG: no limits
// SAFE: explicit size limits on all multipart dimensions
const bb = Busboy({
headers: req.headers,
limits: {
fieldNameSize: 100, // max bytes in field name
fieldSize: 64 * 1024, // 64 KB max for non-file fields
fields: 10, // max 10 non-file fields
fileSize: 10 * 1024 * 1024, // 10 MB max per file
files: 3, // max 3 files per request
parts: 13, // max total parts (fields + files)
headerPairs: 200, // max header key-value pairs per part
},
});
// Handle limit events — Busboy emits these when limits are hit
bb.on('filesLimit', () => {
logger.warn('Multipart files limit reached', { remoteAddr: req.socket.remoteAddress });
req.destroy(); // close connection immediately
});
bb.on('fieldsLimit', () => {
logger.warn('Multipart fields limit reached');
req.destroy();
});
// For each file: explicitly check if the limit was hit during streaming
bb.on('file', (fieldname, fileStream, info) => {
fileStream.on('limit', () => {
logger.warn('File size limit hit during upload', {
filename: info.filename,
fieldname,
});
fileStream.resume(); // consume remaining bytes to release backpressure
// Reject the upload — the file is truncated, don't save it
});
});
SkillAudit findings for multipart form security
Run a free SkillAudit scan to check your MCP server's file upload handling. The scanner submits multipart requests with path traversal payloads, MIME-spoofed files, and oversized parts, then verifies whether the server rejects each one correctly. Related: SSRF protection for defending against upload-triggered server-side fetches and HTTP request smuggling for transport-layer risks in servers that accept binary uploads.