Topic: file upload handling security in MCP servers

File Upload Security in MCP Servers

MCP servers that provide tools accepting file paths, filenames, or raw file contents as arguments introduce a class of vulnerability that does not exist in read-only servers. Path traversal, MIME type spoofing, unbounded file sizes, and insecure temporary file handling are the four categories that appear most frequently in SkillAudit's file-handling corpus — and each has a straightforward defense that should be applied at the tool handler boundary, before any filesystem write occurs.

Path traversal via ../ in filenames

Path traversal occurs when a user-controlled filename argument contains sequences like ../, ../../etc/passwd, or URL-encoded equivalents (%2e%2e%2f) that escape the intended upload directory and write to arbitrary filesystem locations. For an MCP server, the "user" supplying the filename is the LLM — which in turn may be acting on user-supplied content or instructions injected through a prompt injection attack. The LLM is not a trusted input source for filesystem paths.

The canonical defense is to resolve the candidate path to its absolute form and verify it is still inside the allowed directory:

import path from 'path';
import fs from 'fs/promises';
import crypto from 'crypto';

const UPLOAD_DIR = path.resolve('/var/mcp-uploads');  // absolute, pre-resolved
const ALLOWED_EXTENSIONS = new Set(['.txt', '.csv', '.json', '.md', '.log']);
const MAX_FILE_BYTES = 10 * 1024 * 1024;  // 10 MB

async function safeWriteFile(
  userFilename: string,
  content: Buffer
): Promise {
  // 1. Strip directory components — keep only the basename
  const basename = path.basename(userFilename);
  if (!basename || basename === '.' || basename === '..') {
    throw new Error('Invalid filename');
  }

  // 2. Check extension against allowlist
  const ext = path.extname(basename).toLowerCase();
  if (!ALLOWED_EXTENSIONS.has(ext)) {
    throw new Error(`Extension not allowed: ${ext}`);
  }

  // 3. Enforce file size limit before writing
  if (content.length > MAX_FILE_BYTES) {
    throw new Error(`File too large: ${content.length} bytes (max ${MAX_FILE_BYTES})`);
  }

  // 4. Resolve to absolute path and verify it is inside UPLOAD_DIR
  // path.resolve joins UPLOAD_DIR + basename; since basename has no slashes,
  // the result is always UPLOAD_DIR/ — but we verify anyway.
  const resolvedPath = path.resolve(UPLOAD_DIR, basename);
  if (!resolvedPath.startsWith(UPLOAD_DIR + path.sep)) {
    // This would only trigger if UPLOAD_DIR itself ends in sep or basename
    // is somehow empty — belt-and-suspenders check.
    throw new Error('Path traversal detected');
  }

  // 5. Validate content via magic bytes (see next section)
  validateMagicBytes(content, ext);

  // 6. Write atomically: write to temp file, then rename
  const tmpPath = path.join(UPLOAD_DIR, `.tmp-${crypto.randomUUID()}`);
  try {
    await fs.writeFile(tmpPath, content, { mode: 0o640 });
    await fs.rename(tmpPath, resolvedPath);
  } catch (err) {
    // Clean up temp file on failure
    await fs.unlink(tmpPath).catch(() => {});
    throw err;
  }

  return resolvedPath;
}

Using path.basename() to strip directory components before joining is the key step. Do not attempt to sanitize ../ sequences yourself — URL decoding, unicode normalization, and null byte truncation make blocklist-based approaches fragile. path.basename() followed by path.resolve() + startsWith() is the complete, portable defense.

MIME type spoofing — Content-Type vs magic bytes

When a tool argument includes a declared file type (either via a mimeType argument or inferred from the filename extension), a malicious or prompt-injected payload can claim to be a .txt file while containing a PHP script or executable binary. The defense is to read the first few bytes of the file content — the "magic bytes" — and compare them against known signatures for executable formats.

// Magic byte signatures for formats that should never be in a text upload
const BLOCKED_SIGNATURES: Array<{ bytes: Buffer; label: string }> = [
  { bytes: Buffer.from([0x4d, 0x5a]), label: 'Windows PE executable (MZ)' },
  { bytes: Buffer.from([0x7f, 0x45, 0x4c, 0x46]), label: 'ELF executable' },
  { bytes: Buffer.from([0xca, 0xfe, 0xba, 0xbe]), label: 'Mach-O binary' },
  { bytes: Buffer.from([0x50, 0x4b, 0x03, 0x04]), label: 'ZIP archive (could contain executables)' },
  { bytes: Buffer.from('

Magic byte checks are not a complete antivirus scan, but they block the most common executable disguise attempts at near-zero cost. For higher-assurance requirements, integrate a virus scanning API (ClamAV, VirusTotal) as an async post-upload step rather than a blocking pre-write check — scanning can take seconds to minutes for large files.

Destination directory sandboxing and temp file cleanup

Beyond the path traversal check, the upload directory itself should be isolated from application code and system paths. Ideal setup: a dedicated directory owned by the server process user, with no execute permission (chmod a-x /var/mcp-uploads), mounted noexec if possible on Linux. Even if an attacker uploads an executable, a noexec mount prevents it from being run directly.

Temporary files created during upload processing are a cleanup obligation. If the server crashes between creating a temp file and writing it to its final location, the temp file persists. Over time, abandoned temp files accumulate and can fill the filesystem. Two mitigations: first, use a dedicated /tmp/mcp-uploads/ directory (cleaned by OS tmpwatch/systemd-tmpfiles); second, implement a startup cleanup that removes any .tmp-* files older than one hour before the server starts accepting connections.

What SkillAudit checks

The security axis checks for file upload vulnerabilities in tool handlers:

See also

Check your file-handling tools for path traversal and upload security gaps.

Run a free audit → How grading works →