Topic: mcp server file upload security

MCP server file upload security — MIME spoofing, zip slip, polyglot files, and size limits

MCP tools that accept file input are a surprisingly common attack surface: a single tool handler that saves or processes uploaded bytes can be exploited for server-side code execution, arbitrary path writes, or denial of service. The four patterns below cover the most frequently exploited upload flaws found in the SkillAudit corpus — none require a browser and all apply equally to programmatic MCP clients sending crafted payloads.

Pattern 1: MIME type spoofing — trusting the client-supplied content type

When an MCP tool receives a file, the caller controls every field in the request payload including any declared MIME type. Trusting that field to decide how to process the file — or whether to accept it at all — lets an attacker send an executable disguised as an image by simply changing a string. The only reliable check is reading the file's magic bytes: the leading bytes of the actual file content that identify the true format.

The file-type npm package reads up to 4 100 bytes and returns the detected MIME type and extension. Compare its output against an explicit allowlist of permitted types before saving or processing the file.

WRONG — trusting the caller-supplied mimeType field

// tool handler receives { fileName, mimeType, data } where data is base64
async function handleUpload({ fileName, mimeType, data }) {
  const ALLOWED = ['image/png', 'image/jpeg', 'image/webp'];

  // WRONG: mimeType comes from the caller — trivially spoofable
  if (!ALLOWED.includes(mimeType)) {
    throw new Error('File type not allowed');
  }

  const buf = Buffer.from(data, 'base64');
  await fs.writeFile(path.join(UPLOAD_DIR, fileName), buf);
  return { saved: true };
}

RIGHT — detect MIME type from magic bytes with file-type

import { fileTypeFromBuffer } from 'file-type'; // npm i file-type
import path from 'node:path';
import fs from 'node:fs/promises';
import crypto from 'node:crypto';

const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);

async function handleUpload({ data }) {
  const buf = Buffer.from(data, 'base64');

  // RIGHT: detect from actual file content, not caller metadata
  const detected = await fileTypeFromBuffer(buf);
  if (!detected || !ALLOWED_MIME.has(detected.mime)) {
    throw new Error(`Rejected: detected type ${detected?.mime ?? 'unknown'}`);
  }

  // Use a random name — never trust caller-supplied file names
  const safeName = `${crypto.randomUUID()}.${detected.ext}`;
  await fs.writeFile(path.join(UPLOAD_DIR, safeName), buf);
  return { saved: safeName };
}

Pattern 2: Zip slip — path traversal inside extracted archives

ZIP, tar, and other archive formats store each entry with a relative path. A malicious archive can include entries with paths like ../../etc/cron.d/backdoor that, when naively extracted, write files outside the intended destination directory. This is known as zip slip and it is trivially reproducible with the archiver or even the standard zip CLI.

The fix is one line of path math per entry: resolve the full output path, then assert it has the extraction root as a prefix before any write operation. If the assertion fails, abort the entire extraction — partial extraction leaves the filesystem in an unpredictable state.

WRONG — extracting archive entries without checking the resolved path

import AdmZip from 'adm-zip'; // npm i adm-zip

async function extractZip(zipBuffer, destDir) {
  const zip = new AdmZip(zipBuffer);

  for (const entry of zip.getEntries()) {
    if (entry.isDirectory) continue;

    // WRONG: entryName may contain ../../ sequences
    const outPath = path.join(destDir, entry.entryName);
    await fs.mkdir(path.dirname(outPath), { recursive: true });
    await fs.writeFile(outPath, entry.getData());
  }
}

RIGHT — resolve and validate each entry path before writing

import AdmZip from 'adm-zip';
import path from 'node:path';
import fs from 'node:fs/promises';

async function extractZip(zipBuffer, destDir) {
  // Normalize the root so the startsWith check is reliable
  const root = path.resolve(destDir) + path.sep;

  const zip = new AdmZip(zipBuffer);

  for (const entry of zip.getEntries()) {
    if (entry.isDirectory) continue;

    // RIGHT: resolve against root, then verify containment
    const outPath = path.resolve(destDir, entry.entryName);
    if (!outPath.startsWith(root)) {
      throw new Error(
        `Zip slip detected: entry "${entry.entryName}" would escape extraction root`
      );
    }

    await fs.mkdir(path.dirname(outPath), { recursive: true });
    await fs.writeFile(outPath, entry.getData());
  }
}

Pattern 3: Polyglot files — valid as two formats simultaneously

A polyglot file is a single byte sequence that conforms to two different file format specifications at once. The classic example is a JPEG/ZIP polyglot: JPEG parsers read from the start of the file while ZIP parsers read from the end, so both can co-exist in one file without either parser rejecting it. An attacker can upload what your validator accepts as a safe image but your processor executes as a ZIP containing HTML, JavaScript, or server-side scripts.

Magic byte checks catch the most obvious cases, but polyglots deliberately pass them. The defence is to parse the full structure of the declared format — for images, decode the pixel data; for PDFs, walk the object tree — and reject files where parsing the structure raises an error, even if the header bytes look correct.

WRONG — validating only the header magic bytes

// WRONG: checking only the first 4 bytes passes polyglot JPEG/ZIP files
async function validateImage(buf) {
  const isJpeg = buf[0] === 0xFF && buf[1] === 0xD8;
  const isPng  = buf.slice(0, 4).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47]));

  if (!isJpeg && !isPng) {
    throw new Error('Not an image');
  }
  // The file might still be a valid ZIP starting after the JPEG EOI marker
  return true;
}

RIGHT — fully parse the image structure with sharp to reject polyglots

import sharp from 'sharp'; // npm i sharp
import { fileTypeFromBuffer } from 'file-type';

async function validateImage(buf) {
  // Step 1: magic byte check (necessary but not sufficient)
  const detected = await fileTypeFromBuffer(buf);
  const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
  if (!detected || !ALLOWED.has(detected.mime)) {
    throw new Error(`Unexpected file type: ${detected?.mime}`);
  }

  // Step 2: RIGHT — fully decode the image; polyglots typically fail here
  // because the trailing ZIP data corrupts JPEG/PNG structure validation
  try {
    const metadata = await sharp(buf).metadata();
    if (!metadata.width || !metadata.height) {
      throw new Error('Image has no dimensions');
    }
  } catch (err) {
    throw new Error(`Image structure invalid: ${err.message}`);
  }

  // Step 3: assert no ZIP end-of-central-directory signature in the buffer
  // PK\x05\x06 is the ZIP EOCD magic — its presence signals a polyglot
  const EOCD = Buffer.from([0x50, 0x4B, 0x05, 0x06]);
  if (buf.includes(EOCD)) {
    throw new Error('Polyglot detected: ZIP EOCD signature found in image');
  }

  return { width: (await sharp(buf).metadata()).width };
}

Pattern 4: Missing upload size limits — DoS via large file buffering

An MCP tool that reads an entire upload into a Node.js Buffer before checking its size can be crashed or resource-starved by a single request. A 10 GB file sent to a tool that calls Buffer.from(data, 'base64') will allocate 7.5 GB of heap before any application logic runs. Because base64 expands size by ~33%, the problem is compounded when callers encode uploads in base64 as is common in JSON-based MCP payloads.

Enforce limits at the earliest possible point — ideally on the raw byte count of the incoming field before base64 decoding, and again on the decoded buffer size. For streaming transports, apply maxFileSize at the stream level so Node never buffers the full payload.

WRONG — reading the entire buffer before checking size

async function handleUpload({ data }) {
  // WRONG: Buffer.from decodes the entire base64 payload into heap first
  const buf = Buffer.from(data, 'base64');

  // Size check is too late — memory is already allocated
  const MAX = 10 * 1024 * 1024; // 10 MB
  if (buf.length > MAX) {
    throw new Error('File too large');
  }

  await processFile(buf);
}

RIGHT — check encoded size before decoding, then enforce decoded size cap

const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
// base64 encodes 3 bytes as 4 chars, so max encoded length is ceil(max * 4/3)
const MAX_ENCODED_CHARS = Math.ceil(MAX_DECODED_BYTES * 4 / 3) + 4; // +4 for padding

async function handleUpload({ data }) {
  if (typeof data !== 'string') {
    throw new Error('data must be a base64 string');
  }

  // RIGHT: check encoded length before any allocation
  if (data.length > MAX_ENCODED_CHARS) {
    throw new Error(
      `Upload too large: encoded ${data.length} chars exceeds limit of ${MAX_ENCODED_CHARS}`
    );
  }

  const buf = Buffer.from(data, 'base64');

  // Second check on decoded size to handle padding edge cases
  if (buf.length > MAX_DECODED_BYTES) {
    throw new Error(`Decoded file exceeds ${MAX_DECODED_BYTES} byte limit`);
  }

  await processFile(buf);
}

// For streaming HTTP transports use busboy with limits option:
// busboy({ headers: req.headers, limits: { fileSize: MAX_DECODED_BYTES } })

How SkillAudit detects file upload security issues

SkillAudit's static analysis scans every tool handler in an MCP server's source tree for file-processing patterns. It flags Buffer.from(x, 'base64') calls that are not preceded by an encoded-length guard, identifies path.join(dest, entry) patterns inside archive-extraction loops that lack a subsequent startsWith containment check, and marks any branch that gates file processing on a caller-supplied MIME string rather than a file-type or equivalent magic-byte call. These findings contribute to the Input Validation axis of the SkillAudit grade, which covers all trust-boundary checks on inbound data.

A missing size limit before buffering counts as a high-severity finding because it enables a single unauthenticated request to exhaust server memory and deny service to all other MCP clients. MIME spoofing and zip slip are flagged as medium-to-high depending on whether the tool writes to disk or passes content to further processing stages. Run a free scan at skillaudit.dev to see which of these patterns exist in your MCP server and get a prioritized remediation checklist.