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

SkillAudit Findings — Multipart Form Security

CRITICAL−22 pts: Client-supplied Content-Disposition: filename used directly as the saved file path. Path traversal payload writes attacker-controlled files outside the upload directory.
HIGH−18 pts: File type validated using client-supplied Content-Type header only. MIME spoofing allows uploading executable scripts as image/jpeg; magic number check not performed.
HIGH−16 pts: No fileSize or files limits on multipart parser. Attacker uploads arbitrarily large file or thousands of parts to exhaust memory and crash the MCP server process.
MEDIUM−8 pts: Fixed or predictable multipart boundary in server-generated multipart tool responses. Attacker-controlled content in fetched external files can inject boundary strings and split the response.

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.