Topic: mcp server multipart upload security

MCP server multipart upload security — filename injection, MIME bypass, magic byte validation, upload directory containment

MCP tool handlers that accept file uploads process attacker-controlled filenames, content-type headers, and binary data. Each is a separate attack vector: filenames can contain path traversal sequences; Content-Type headers can claim any MIME type regardless of actual content; binary data can be a decompression bomb, a polyglot file that parses as two types simultaneously, or an oversized upload designed to exhaust memory before any size check runs. This page covers the full upload attack surface and the defense for each class.

1. Filename injection — path traversal and null byte attacks

The filename in a multipart upload is attacker-controlled. Using it directly in file system operations can write outside the intended upload directory:

// VULNERABLE: using the original filename directly in file path construction
import multer from "multer";
import path from "path";

const upload = multer({
  storage: multer.diskStorage({
    destination: "./uploads/",
    filename: (req, file, cb) => {
      // VULNERABLE: attacker controls file.originalname
      // Path traversal: "../../server.js" overwrites application files
      // Null byte: "evil.php\x00.jpg" may pass extension check but be stored as evil.php
      // Long filename: can cause filesystem errors if not truncated
      cb(null, file.originalname);
    },
  }),
});

// SECURE: generate a safe, random filename — never use the original
import crypto from "crypto";
import { extname } from "path";

const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".pdf", ".txt"]);

function safeFilename(originalname: string): string {
  // Extract extension from original name (still attacker-controlled — only use after allowlist)
  const rawExt = extname(originalname).toLowerCase();
  const safeExt = ALLOWED_EXTENSIONS.has(rawExt) ? rawExt : "";
  // Generate a random filename — never include any part of the original name
  return `${crypto.randomUUID()}${safeExt}`;
}

const upload = multer({
  storage: multer.diskStorage({
    destination: "./uploads/",
    filename: (req, file, cb) => cb(null, safeFilename(file.originalname)),
  }),
  limits: { fileSize: 10 * 1024 * 1024 },  // 10 MB limit before buffering to disk
});

// After storing the file, verify it landed in the expected directory
async function verifyUploadContainment(filePath: string): Promise<void> {
  const uploadDir = path.resolve("./uploads");
  const resolved = await fs.realpath(filePath);  // Resolves symlinks
  if (!resolved.startsWith(uploadDir + path.sep)) {
    await fs.unlink(filePath).catch(() => {});
    throw new Error("Upload containment violation: file landed outside upload directory");
  }
}

2. MIME type spoofing — trusting Content-Type headers

The Content-Type header in a multipart upload is attacker-controlled and tells you nothing about the actual file content. A PHP webshell uploaded with Content-Type: image/jpeg will pass any filter that reads the header rather than inspecting the file bytes:

// VULNERABLE: trusting the Content-Type header
server.tool("upload_image", { file: z.instanceof(Buffer) }, async (args, ctx) => {
  // Content-Type comes from the attacker's request — meaningless as a security check
  const contentType = ctx.request.headers["content-type"];
  if (!contentType?.startsWith("image/")) {
    throw new Error("Only images allowed");  // Attacker sets Content-Type: image/jpeg
  }
  // Stores a PHP webshell as "image.jpg"
  await fs.writeFile(`./uploads/${safeFilename}`, args.file);
});

// SECURE: validate actual file content using magic bytes (file signature)
import { fileTypeFromBuffer } from "file-type";

const ALLOWED_MIME_TYPES = new Set([
  "image/jpeg",  // FF D8 FF
  "image/png",   // 89 50 4E 47
  "image/gif",   // 47 49 46 38
  "image/webp",  // 52 49 46 46 ... 57 45 42 50
  "application/pdf",  // 25 50 44 46
]);

async function validateFileType(buffer: Buffer, maxSize: number): Promise<string> {
  if (buffer.length > maxSize) {
    throw new Error(`File too large: ${buffer.length} bytes (max ${maxSize})`);
  }
  if (buffer.length < 12) {
    throw new Error("File too small to determine type");
  }

  const result = await fileTypeFromBuffer(buffer);
  if (!result || !ALLOWED_MIME_TYPES.has(result.mime)) {
    throw new Error(
      `File type not allowed: ${result?.mime ?? "unknown"}. ` +
      `Allowed types: ${[...ALLOWED_MIME_TYPES].join(", ")}`
    );
  }
  return result.mime;
}

// The file-type library reads actual magic bytes — not the Content-Type header
// It identifies JPEG by 0xFF 0xD8 0xFF, PNG by 0x89 0x50 0x4E 0x47, etc.
// An attacker who renames evil.php to image.jpg will fail this check
// because the PHP file starts with <?php, not a JPEG magic header

3. Decompression bombs — DoS via inflated archives

A "pixel bomb" or ZIP bomb is a file that is small on disk but expands to gigabytes when decompressed or rendered. An MCP server that reads file metadata (dimensions, page count) or extracts archives can exhaust memory processing a single 1 KB upload:

// VULNERABLE: reading image dimensions without size limits
import sharp from "sharp";

server.tool("get_image_dimensions", {}, async (_, ctx) => {
  const fileBuffer = ctx.uploadBuffer;
  // VULNERABLE: some image formats (PNG, TIFF) can claim enormous dimensions
  // in their header, causing sharp to allocate gigabytes of memory
  const metadata = await sharp(fileBuffer).metadata();
  return { content: [{ type: "text", text: `${metadata.width}x${metadata.height}` }] };
});

// SECURE: validate file size and dimensions before full processing
const MAX_IMAGE_DIMENSION = 8000;   // pixels
const MAX_IMAGE_MEGAPIXELS = 25;    // ~6000x4000

async function safeGetImageMetadata(buffer: Buffer) {
  const metadata = await sharp(buffer).metadata();

  if (!metadata.width || !metadata.height) {
    throw new Error("Could not determine image dimensions");
  }

  if (metadata.width > MAX_IMAGE_DIMENSION || metadata.height > MAX_IMAGE_DIMENSION) {
    throw new Error(
      `Image dimensions too large: ${metadata.width}x${metadata.height}. ` +
      `Maximum is ${MAX_IMAGE_DIMENSION}px on each side.`
    );
  }

  const megapixels = (metadata.width * metadata.height) / 1_000_000;
  if (megapixels > MAX_IMAGE_MEGAPIXELS) {
    throw new Error(`Image too large: ${megapixels.toFixed(1)} MP. Maximum is ${MAX_IMAGE_MEGAPIXELS} MP.`);
  }

  return metadata;
}

// For ZIP archives: check compressed vs uncompressed ratio before extraction
const MAX_COMPRESSION_RATIO = 100;   // Reject if compressed/uncompressed > 100:1
const MAX_EXTRACTED_SIZE = 500 * 1024 * 1024;  // 500 MB max extracted total

async function safeExtractZip(buffer: Buffer, destDir: string): Promise<string[]> {
  const AdmZip = require("adm-zip");
  const zip = new AdmZip(buffer);
  const entries = zip.getEntries();

  let totalExtractedSize = 0;
  const extractedFiles: string[] = [];

  for (const entry of entries) {
    // Check expansion ratio
    const ratio = entry.header.size / (entry.header.compressedSize || 1);
    if (ratio > MAX_COMPRESSION_RATIO) {
      throw new Error(`ZIP bomb detected: entry '${entry.name}' expands ${ratio.toFixed(0)}:1`);
    }

    totalExtractedSize += entry.header.size;
    if (totalExtractedSize > MAX_EXTRACTED_SIZE) {
      throw new Error(`Archive total uncompressed size exceeds ${MAX_EXTRACTED_SIZE / 1024 / 1024} MB`);
    }

    // Prevent zip slip: validate each entry path against destination directory
    const entryPath = path.join(destDir, entry.entryName);
    const resolvedEntry = path.resolve(entryPath);
    if (!resolvedEntry.startsWith(path.resolve(destDir) + path.sep)) {
      throw new Error(`Zip slip detected: entry '${entry.entryName}' would escape destination`);
    }

    extractedFiles.push(resolvedEntry);
  }

  // Only extract after all entries pass validation
  zip.extractAllTo(destDir, true);
  return extractedFiles;
}

4. Virus scanning for uploaded files

For MCP servers that distribute uploaded files to other users or execute uploaded content, antivirus scanning should run before the file is stored in permanent storage:

import NodeClam from "clamscan";

const ClamAV = await new NodeClam().init({
  clamdscan: {
    socket: "/var/run/clamav/clamd.ctl",  // Unix socket — faster than TCP
    active: true,
  },
});

async function scanFile(filePath: string): Promise<void> {
  const { isInfected, viruses } = await ClamAV.scanFile(filePath);
  if (isInfected) {
    await fs.unlink(filePath).catch(() => {});  // Delete infected file
    throw new Error(`Upload rejected: file contains malware (${viruses?.join(", ")})`);
  }
}

// Scanning pipeline: temp file → size check → magic byte check → AV scan → permanent storage
// Never move to permanent storage before all checks pass
async function processUpload(buffer: Buffer, originalFilename: string): Promise<string> {
  // 1. Size check before writing to disk
  if (buffer.length > 10 * 1024 * 1024) throw new Error("File too large");

  // 2. Magic byte validation
  const mimeType = await validateFileType(buffer, 10 * 1024 * 1024);

  // 3. Write to temp location
  const tempPath = `/tmp/upload-${crypto.randomUUID()}`;
  await fs.writeFile(tempPath, buffer);

  // 4. AV scan
  await scanFile(tempPath);  // Throws and deletes if infected

  // 5. Move to permanent storage with safe filename
  const safeName = safeFilename(originalFilename);
  const finalPath = path.join("./uploads", safeName);
  await fs.rename(tempPath, finalPath);

  // 6. Verify containment after move
  await verifyUploadContainment(finalPath);

  return safeName;
}

SkillAudit findings for multipart upload handlers

CRITICAL −25 Original filename used in file system path construction — path traversal allows writing outside upload directory
CRITICAL −22 Content-Type header trusted for file type validation — attacker uploads PHP webshell as image/jpeg
HIGH −18 No file size limit before reading file into memory — decompression bomb or pixel bomb exhausts server memory
HIGH −15 ZIP extraction without zip-slip validation — archive entry paths can escape destination directory
MEDIUM −10 No upload directory containment check after file write — symlink race condition can redirect file outside intended directory

Run a SkillAudit scan to detect filename sanitization gaps, Content-Type trust, and missing size limits in upload handlers. See also path traversal bypass patterns for the full set of path escaping techniques.