← Blog

Claude skill file upload security

MCP skills that accept file content as tool arguments — document analysis skills, image processing tools, archive inspection utilities, code review assistants — introduce file upload attack surface at the LLM tool call boundary. Unlike web form file uploads where the browser constructs the multipart request, MCP file uploads arrive as base64-encoded content in JSON tool arguments where the LLM has synthesised the values from user-supplied or attacker-influenced sources. Each of the four patterns in SA-SKL-FIL describes a distinct failure mode: trusting the MIME type declared in the argument rather than inspecting the file’s actual magic bytes, accepting file content without a size limit, deriving a disk path from the filename argument without traversal protection, and extracting archives without recursion depth or size guards that prevent zip bomb exhaustion.

By SkillAudit  |  3 Nov 2026

File upload vulnerabilities in web applications are well-understood. What makes them different in the MCP context is that the file content does not arrive via a browser form submission — it arrives as a string in a JSON tool argument, often assembled by the LLM from content in the conversation context. The attacker does not need to interact with a file picker or bypass a browser-level file type restriction. They need only to influence the LLM’s context with content that causes it to construct a malicious tool call. This makes the skill’s own validation logic the only defence; browser-side restrictions and multipart form validation do not exist in the MCP calling model.

SA-SKL-FIL-001: file type validated by MIME type from args not by magic byte inspection

Skills that process files based on their declared type — an image analysis skill that expects JPEG, a PDF extraction skill that expects PDF, a config skill that expects JSON or YAML — often validate the file type by checking the mimeType argument that the LLM passed alongside the base64-encoded file content. This argument is entirely attacker-controlled: an attacker who influences the LLM to call the skill can set mimeType: 'image/jpeg' while providing a base64-encoded executable, a PHP webshell, or a polyglot file that is simultaneously valid JPEG and valid JavaScript. The declared MIME type is metadata, not content; only magic byte inspection — examining the first N bytes of the actual file content to verify they match the signature of the declared type — confirms that the file is what the caller claims. The file-type npm package performs magic byte inspection for over 100 file types and returns the actual detected type, which must be checked against the allowlist of types the skill accepts regardless of the declared MIME type.

// VULNERABLE: file type validated from args.mimeType only
import { z } from 'zod';

const analyzeImageTool = {
  name: 'analyze_image',
  description: 'Analyze an image file',
  inputSchema: z.object({
    imageData: z.string(),     // base64-encoded file content
    mimeType: z.string(),      // declared by caller — ATTACKER-CONTROLLED
    filename: z.string(),
  }),

  async execute(args: { imageData: string; mimeType: string; filename: string }) {
    // BUG: trust the declared mimeType from args — any caller sets this to anything
    if (!['image/jpeg', 'image/png', 'image/gif'].includes(args.mimeType)) {
      throw new Error('Unsupported image type');
    }
    // Attack: mimeType='image/jpeg', imageData=base64()
    // Skill saves as .jpg, web server serves it, browser/PHP executes the payload
    const buffer = Buffer.from(args.imageData, 'base64');
    await fs.writeFile(`/uploads/${args.filename}`, buffer);
    return analyzeImage(buffer);
  }
};

// SECURE: inspect magic bytes to determine actual file type
import fileType from 'file-type';

const analyzeImageTool = {
  name: 'analyze_image',
  inputSchema: z.object({
    imageData: z.string().max(10 * 1024 * 1024 * 1.37),  // 10MB in base64 ≈ 13.7MB chars
    filename: z.string().regex(/^[a-zA-Z0-9\-_.]+$/).max(100),
    // mimeType removed from schema — we determine it from magic bytes
  }),

  async execute(args: { imageData: string; filename: string }) {
    const buffer = Buffer.from(args.imageData, 'base64');

    // Inspect actual magic bytes — not declared type
    const detected = await fileType.fromBuffer(buffer);

    const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
    if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
      throw new Error(
        `Rejected: actual file type is ${detected?.mime ?? 'unknown'}, expected an image`
      );
    }

    // Use extension from detected type — not from caller-supplied filename
    const safeFilename = `${crypto.randomUUID()}.${detected.ext}`;
    const uploadPath = path.join(UPLOAD_DIR, safeFilename);
    await fs.writeFile(uploadPath, buffer);

    return analyzeImage(buffer);
  }
};

// DETECTION (SkillAudit):
// if (args.mimeType) check without fileType.fromBuffer() magic byte validation
const mimeBypass = searchAST(
  'args.mimeType | args.type | args.contentType CHECKED WITHOUT fileType.fromBuffer(*)'
);
if (mimeBypass) → HIGH  // SA-SKL-FIL-001

Magic byte inspection is the only reliable file type check: file extensions and MIME type arguments are metadata declared by the sender, not properties of the file itself. A ZIP file starting with the JPEG magic bytes FF D8 FF appended before the ZIP local file header is a polyglot that passes JPEG magic byte checks while being processable as a ZIP. Magic byte inspection is a necessary but not sufficient check — use a library like file-type that understands the structure of each format, not just the first two bytes. Combine it with strict filename sanitisation and server-side generated filenames for saved uploads.

HIGH
SA-SKL-FIL-001 — MIME type bypass: file type validated via args.mimeType or args.contentType string comparison without magic byte inspection via fileType.fromBuffer(); attacker passes any MIME type with any file content; executable payloads processed as declared type (image, PDF, JSON). Detection: MIME type check on args.* without fileType.fromBuffer() call → HIGH.

SA-SKL-FIL-002: no file size limit on file argument causes OOM via oversized upload

MCP tool arguments arrive as JSON strings. Base64-encoded file content in a string argument has no built-in size limit unless the skill’s input schema enforces one. A file argument without a maxLength constraint or a byte-count check allows an attacker to submit a multi-gigabyte payload. When the skill decodes the base64 string to a Buffer, Node.js allocates memory for the decoded bytes. A 1 GB base64 string decodes to approximately 750 MB of binary data. The process’s heap limit (default ~1.5 GB in Node.js) is quickly exhausted, triggering either a JavaScript heap out-of-memory error that kills the process or progressive memory pressure that slows or crashes the entire MCP server. A single malicious LLM session with an oversized file argument can take down the skill server for all users. The fix requires two layers: a Zod .max() constraint on the string length in the input schema (enforced before any processing), and an explicit byte count check on the decoded buffer before memory-intensive operations.

// VULNERABLE: no size limit on file argument
import { z } from 'zod';

const processDocumentTool = {
  name: 'process_document',
  inputSchema: z.object({
    fileContent: z.string(),  // BUG: no .max() constraint — accepts gigabytes
    filename: z.string(),
  }),

  async execute(args: { fileContent: string; filename: string }) {
    // fileContent could be 1 GB base64 string — decoded below
    const buffer = Buffer.from(args.fileContent, 'base64');
    // ↑ 1 GB base64 → 750 MB heap allocation — kills process
    // ↑ No size check before decoding

    return await extractText(buffer);
  }
};

// ALSO VULNERABLE: image processing with no limit
// 100 MB PNG decoded to RGBA pixels: 100 MB × ~4 expansion = 400 MB for pixel array
// Then librsvg or sharp loads it → another 400 MB → OOM

// SECURE: enforce size limit at schema level and before decode
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;  // 10 MB
const MAX_BASE64_LENGTH = Math.ceil(MAX_FILE_SIZE_BYTES * 1.37);  // base64 overhead

const processDocumentTool = {
  name: 'process_document',
  inputSchema: z.object({
    // .max() enforced by Zod before execute() is called
    fileContent: z.string().max(MAX_BASE64_LENGTH, 'File too large (max 10 MB)'),
    filename: z.string().regex(/^[a-zA-Z0-9\-_.]+$/).max(100),
  }),

  async execute(args: { fileContent: string; filename: string }) {
    // Belt-and-suspenders: verify byte count after decode, before processing
    // (Zod caught the base64 string length; this catches any encoding discrepancy)
    const buffer = Buffer.from(args.fileContent, 'base64');

    if (buffer.length > MAX_FILE_SIZE_BYTES) {
      throw new Error(`File too large: ${buffer.length} bytes (max ${MAX_FILE_SIZE_BYTES})`);
    }

    // For image processing: also check decoded pixel dimensions
    // Large PNG with tiny file size but huge canvas is a "decompression bomb"
    // Use sharp({ limitInputPixels: 25_000_000 }) to cap pixel array size too

    return await extractText(buffer);
  }
};

// For streaming file processing (avoids full allocation):
async function processLargeFile(fileContent: string): Promise<string> {
  // Convert base64 to stream without full allocation:
  const readable = Readable.from(Buffer.from(fileContent, 'base64'));

  let bytesRead = 0;
  const limitedStream = new Transform({
    transform(chunk, _, callback) {
      bytesRead += chunk.length;
      if (bytesRead > MAX_FILE_SIZE_BYTES) {
        callback(new Error('File exceeds size limit'));
      } else {
        callback(null, chunk);
      }
    }
  });

  return new Promise((resolve, reject) => {
    readable.pipe(limitedStream).pipe(extractTextStream())
      .on('finish', resolve).on('error', reject);
  });
}

// DETECTION (SkillAudit):
// z.string() file argument without .max() AND Buffer.from(args.*fileContent*, 'base64')
const noSizeLimit = searchAST(
  'Buffer.from(args.fileContent, "base64") WITHOUT z.string().max(*) on fileContent'
);
if (noSizeLimit) → HIGH  // SA-SKL-FIL-002

Zod .max() is the cheapest guard: the schema check runs before any Buffer allocation. Set it to the base64-encoded size of your maximum acceptable file (10 MB binary ≈ 13.7 M characters of base64). Follow it with a byte-count check after decoding for defence-in-depth. For image processing, also configure the image library’s pixel dimension limit (sharp’s limitInputPixels, ImageMagick’s -limit memory) to guard against pixel-count bombs that have a small file size but expand to gigabytes of pixel data when decoded.

HIGH
SA-SKL-FIL-002 — No file size limit: base64 file argument without z.string().max() constraint or byte-count check after decode; attacker supplies multi-gigabyte base64 payload; Buffer.from(args.fileContent, 'base64') exhausts Node.js heap; OOM kills skill server process for all users. Detection: Buffer.from(args.*file*, 'base64') without .max() on the schema field → HIGH.

SA-SKL-FIL-003: path traversal in filename argument when saving to disk

Skills that save uploaded files to disk using a filename derived from the tool argument are vulnerable to directory traversal. If the filename argument is not sanitised before being used in a disk path, an attacker who can influence the LLM to include ../../../etc/cron.d/evil.sh or ../../../../var/www/html/shell.php as the filename causes the skill to write outside the intended upload directory. The three traversal patterns to prevent are: literal ../ sequences in the filename, URL-encoded %2e%2e%2f variants decoded before the path is constructed, and absolute paths (/etc/passwd) passed as the filename. The correct fix uses path.basename() to extract only the final filename component (stripping all directory separators), then constructs the full path with path.resolve(), and verifies that the resolved path still starts with the intended upload directory. This prevents traversal even if path.basename() is somehow bypassed by a creative Unicode normalisation.

// VULNERABLE: filename from args used directly in path construction
import path from 'path';
import fs from 'fs/promises';

const UPLOAD_DIR = '/var/skill/uploads';

async function saveUpload(args: { fileContent: string; filename: string }) {
  // BUG: path.join does not prevent traversal — it normalises but still escapes
  const filePath = path.join(UPLOAD_DIR, args.filename);
  // Attack: args.filename = "../../../etc/cron.d/evil.sh"
  // path.join → "/var/skill/uploads/../../../etc/cron.d/evil.sh"
  // Normalises to → "/etc/cron.d/evil.sh"
  // Arbitrary file write at cron.d/evil.sh → RCE via cron
  await fs.writeFile(filePath, Buffer.from(args.fileContent, 'base64'));
}

// ALSO VULNERABLE: path.resolve does not prevent traversal if filename is absolute
async function saveUpload2(args: { fileContent: string; filename: string }) {
  const filePath = path.resolve(UPLOAD_DIR, args.filename);
  // Attack: args.filename = "/etc/passwd"
  // path.resolve("...", "/etc/passwd") → "/etc/passwd" (absolute path wins)
  await fs.writeFile(filePath, Buffer.from(args.fileContent, 'base64'));
}

// ALSO VULNERABLE: null byte injection in filename (legacy Node.js)
// args.filename = "safe.jpg\x00.php" → fs sees "safe.jpg" but some libs see ".php"

// SECURE: basename + resolve + startsWith boundary check
async function saveUpload(args: { fileContent: string; filename: string }) {
  // Step 1: extract only the filename component — strip all directory separators
  const basename = path.basename(args.filename);

  // Step 2: reject empty, hidden files (.), and names with null bytes
  if (!basename || basename === '.' || basename === '..' || basename.includes('\x00')) {
    throw new Error('Invalid filename');
  }

  // Step 3: sanitise to safe character set
  const safeBasename = basename.replace(/[^a-zA-Z0-9\-_.]/g, '_').slice(0, 200);

  // Step 4: construct full path and verify it stays within upload dir
  const fullPath = path.resolve(UPLOAD_DIR, safeBasename);
  if (!fullPath.startsWith(UPLOAD_DIR + path.sep)) {
    // Should be impossible after basename + sanitise — but verify anyway
    throw new Error('Path traversal detected');
  }

  // Step 5: generate a server-assigned filename to avoid collisions and name attacks
  // Do not use the original filename for the actual disk path — use it only as metadata
  const ext = path.extname(safeBasename).toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin';
  const serverFilename = `${crypto.randomUUID()}.${ext}`;
  const serverPath = path.resolve(UPLOAD_DIR, serverFilename);

  // serverPath is guaranteed to be inside UPLOAD_DIR — basename was extracted,
  // sanitised, and a new UUID-based name was generated
  await fs.writeFile(serverPath, Buffer.from(args.fileContent, 'base64'));

  return { storedAs: serverFilename, originalName: safeBasename };
}

// DETECTION (SkillAudit):
// path.join(UPLOAD_DIR, args.filename) WITHOUT basename extraction first
// OR path.resolve(UPLOAD_DIR, args.filename) WITHOUT startsWith(UPLOAD_DIR) check
const pathTraversal = searchAST(
  'path.join(UPLOAD_DIR, args.filename) WITHOUT path.basename(args.filename) preprocessing'
);
if (pathTraversal) → CRITICAL  // SA-SKL-FIL-003

path.join does not protect against traversal: it normalises the combined path, which means that path.join('/uploads', '../etc/passwd') returns '/etc/passwd'. The correct defence chain is: path.basename() first (strips directory components), then sanitise the result to a safe character set, then path.resolve() to construct the full path, then verify with startsWith(UPLOAD_DIR + '/'). Ideally, never use the caller-supplied filename as the disk filename at all — use a UUID and store the original name as metadata only.

CRITICAL
SA-SKL-FIL-003 — Path traversal in filename arg: path.join(UPLOAD_DIR, args.filename) without path.basename() preprocessing; attacker passes ../../../etc/cron.d/evil.sh; path.join normalises to /etc/cron.d/evil.sh; arbitrary file write outside upload directory enables RCE via cron or webshell. Detection: path.join(UPLOAD_DIR, args.filename) without path.basename(args.filename) first → CRITICAL.

SA-SKL-FIL-004: zip bomb via recursive archive extraction without size or depth limit

Archive files (ZIP, tar, gzip) can achieve extreme compression ratios: the “42.zip” zip bomb compresses to 42 KB but extracts to 4.5 petabytes of nested zeros. A recursive extraction algorithm with no depth or total-size limit will attempt to extract all layers, exhausting disk space and memory and making the skill server unresponsive for all users. A less dramatic but more practical attack uses a ZIP containing 50,000 small files, each named ../../../etc/cron.d/file-N.sh, combined with a total size within the zip bomb limit — the path traversal vector at scale. Skills that call extract-zip, adm-zip, node-tar, or similar libraries must set explicit limits on: total extracted size (sum of all files, not just individual files), maximum file count within the archive, maximum extraction depth for nested archives, and sanitised paths for all extracted entries (the path traversal guard from FIL-003 applied to each archive member path).

// VULNERABLE: extract-zip with no limits
import extract from 'extract-zip';
import path from 'path';

async function extractArchive(args: { archiveContent: string; extractDir: string }) {
  const archivePath = path.join('/tmp', `${crypto.randomUUID()}.zip`);
  await fs.writeFile(archivePath, Buffer.from(args.archiveContent, 'base64'));

  // BUG: extract-zip by default extracts everything to extractDir with no limits
  // 42.zip: 42 KB file → 4.5 PB extracted → disk exhaustion + OOM → server crash
  // Path traversal: archive members with "../" paths escape extractDir
  await extract(archivePath, { dir: path.resolve(args.extractDir) });
  // ↑ Also: args.extractDir not validated — path traversal to any directory

  return { extracted: true };
}

// ALSO VULNERABLE: node-tar without limits
import tar from 'tar';
async function extractTar(args: { tarContent: string }) {
  const buffer = Buffer.from(args.tarContent, 'base64');
  // No size, count, or path traversal limits
  await tar.x({ file: tarPath, cwd: extractDir });
}

// SECURE: enforce total size, file count, path safety for every extracted entry
import extract from 'extract-zip';
import AdmZip from 'adm-zip';

const MAX_EXTRACT_BYTES = 50 * 1024 * 1024;  // 50 MB total extracted
const MAX_ENTRY_COUNT = 1000;
const MAX_ARCHIVE_BYTES = 10 * 1024 * 1024;  // 10 MB zip file itself

async function extractArchive(args: { archiveContent: string }) {
  const buffer = Buffer.from(args.archiveContent, 'base64');

  // Check archive file size first
  if (buffer.length > MAX_ARCHIVE_BYTES) {
    throw new Error('Archive too large');
  }

  // Use AdmZip for inspection before extraction
  const zip = new AdmZip(buffer);
  const entries = zip.getEntries();

  // Check entry count
  if (entries.length > MAX_ENTRY_COUNT) {
    throw new Error(`Archive has too many entries: ${entries.length} (max ${MAX_ENTRY_COUNT})`);
  }

  // Pre-scan all entries before extracting anything
  let totalSize = 0;
  const extractDir = path.resolve('/tmp', crypto.randomUUID());

  for (const entry of entries) {
    // Check for path traversal in entry name
    const entryPath = path.resolve(extractDir, entry.entryName);
    if (!entryPath.startsWith(extractDir + path.sep)) {
      throw new Error(`Path traversal in archive: ${entry.entryName}`);
    }

    // Check compression ratio (zip bomb detection)
    const compressedSize = entry.header.compressedSize;
    const uncompressedSize = entry.header.size;

    if (compressedSize > 0 && uncompressedSize / compressedSize > 100) {
      throw new Error(`Suspicious compression ratio for entry: ${entry.entryName}`);
    }

    // Accumulate total extracted size
    totalSize += uncompressedSize;
    if (totalSize > MAX_EXTRACT_BYTES) {
      throw new Error(`Archive would extract to ${totalSize} bytes (max ${MAX_EXTRACT_BYTES})`);
    }
  }

  // All entries passed — safe to extract
  await fs.mkdir(extractDir, { recursive: true });
  zip.extractAllTo(extractDir, true);

  return { extractDir, entryCount: entries.length, totalBytes: totalSize };
}

// DETECTION (SkillAudit):
// extract-zip | adm-zip | node-tar calls without entry count + total size + path traversal checks
const zipBomb = searchAST(
  '(extract(*) | zip.extractAllTo(*) | tar.x(*)) WITHOUT ' +
  '(entryCountCheck && totalSizeCheck && pathTraversalCheck)'
);
if (zipBomb) → HIGH  // SA-SKL-FIL-004

Pre-scan the archive before extracting anything: inspect all entries in the ZIP central directory (AdmZip getEntries()) to check path traversal, entry count, total uncompressed size, and compression ratio before writing a single byte to disk. The central directory is read without extracting; it gives you all the metadata you need to decide whether the archive is safe to extract. A compression ratio above 100:1 on any single entry is a strong signal of a zip bomb; total extracted size above 50–100 MB is a safe upper bound for most skill use cases.

HIGH
SA-SKL-FIL-004 — Zip bomb via unbounded extraction: extract-zip or adm-zip called without entry count limit, total extracted size limit, compression ratio check, or per-entry path traversal validation; multi-layer zip bomb exhausts disk and memory; path traversal in entry names escapes extraction directory. Detection: archive extraction without all four guards (entry count, total size, ratio, path traversal per entry) → HIGH.

Remediation summary

FindingSeverityFix
FIL-001: MIME type bypassHIGHUse file-type library to inspect magic bytes after base64 decode; validate detected MIME type against an explicit allowlist; remove caller-supplied MIME type from schema; use server-generated UUID filenames
FIL-002: No file size limitHIGHAdd z.string().max(MAX_BASE64_LENGTH) to schema; check buffer.length after decode; set image library pixel limits (sharp limitInputPixels); cap at 10 MB binary for most use cases
FIL-003: Path traversal in filenameCRITICALApply path.basename() before any path construction; sanitise to [a-zA-Z0-9\-_.]; verify final path.resolve() still starts with UPLOAD_DIR + '/'; prefer server-generated UUID filenames over caller-supplied names
FIL-004: Zip bomb via unbounded extractionHIGHPre-scan all archive entries via AdmZip.getEntries() before extraction; enforce entry count (max 1000), total size (max 50 MB), compression ratio (max 100:1), and per-entry path traversal checks; only extract after all checks pass

SkillAudit detects all four SA-SKL-FIL-001 through FIL-004 file upload security patterns through static analysis of file processing tool handlers and archive extraction code. For related input validation patterns in MCP skills, see SA-SKL-STR JSON schema validation bypass and SA-SKL-STR path traversal security. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for file upload vulnerabilities

SkillAudit detects MIME type bypass, missing size limits, path traversal in filename args, and zip bomb vulnerabilities across the full SA-SKL-FIL family in 60 seconds.

Get your free audit