← All Articles 🔒 Security Vulnerabilities High By Nishant Kumar Verma Updated

Insecure File Uploads in AI-Generated Code: No Validation, No Limits

AI tools generate file upload endpoints without type validation or size limits. Learn how attackers exploit this and how to fix it.

SEC-011

Quick Answer

AI-generated file upload endpoints routinely accept any file type at any size without validation. This lets attackers upload executable scripts disguised as images, exhaust server disk space, and cause denial-of-service conditions. A secure upload handler must validate MIME type from the file's magic bytes (not the filename), enforce a maximum file size, and store uploads outside the web root.

Why File Uploads Are One of the Riskiest Features to Vibe-Code

File uploads are among the riskiest features in a web application, alongside authentication and database queries. Yet they're also one of the features developers most frequently build with AI tools - "add a profile picture upload" or "let users upload documents" are common prompts. The AI generates working code fast, and it works perfectly for legitimate users. For an attacker, an unvalidated upload endpoint is an open door.

The OWASP Top 10 (2025) maps unrestricted upload of a file with a dangerous type (CWE-434) to A06, Insecure Design. More apps now add upload features through AI-generated code, and each one is a new place for this flaw to appear.

File handling is one of the features where AI-generated code tends to leave out security checks. The reason: secure file handling requires multiple coordinated checks, and AI tools often implement the functional part while omitting the security checks.

What AI-Generated Upload Code Looks Like

Here's the kind of code an AI tool like Bolt or Cursor can produce when asked to "add file upload to my Next.js app":

// ❌ BAD - No file type validation, no size limit
// Next.js App Router: app/api/upload/route.ts
import { writeFile } from 'fs/promises';
import path from 'path';

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return Response.json({ error: 'No file provided' }, { status: 400 });
  }

  // No MIME type check - accepts .php, .exe, .sh, anything
  // No file size limit - attacker can upload multi-GB files
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // Stored in public directory - directly accessible via URL!
  const filepath = path.join(process.cwd(), 'public/uploads', file.name);
  await writeFile(filepath, buffer);

  // Returns the public URL - an uploaded .html or .svg file runs its scripts on your domain
  return Response.json({ url: `/uploads/${file.name}` });
}

// ❌ BAD - Express Multer without validation
import multer from 'multer';

const upload = multer({ dest: 'uploads/' }); // No limits, no filter

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ filename: req.file.filename });
});

This code has four distinct vulnerabilities: no file type validation (an attacker uploads an HTML or SVG file with a script, or a PHP web shell if the server also runs PHP), no size limit (disk exhaustion attack), storage in the public directory (the uploaded file is directly accessible via URL), and the original filename is used (path traversal via filenames like ../../server.js).

The Attack Scenarios

Attack How it works Missing control
Web shell upload Upload shell.php, execute via URL to run server commands File type validation
MIME type spoofing Rename malware.exe to image.jpg, bypass extension checks Magic byte validation
Disk exhaustion (DoS) Upload hundreds of 1 GB files until disk is full File size limit
Path traversal Upload with filename ../../config.js to overwrite server files Filename sanitization
Stored XSS via SVG Upload SVG with embedded <script> tag, serve to other users SVG sanitization or blocking
Server-side execution Upload script to web root, access via URL to execute Storage outside web root

How to Build a Secure Upload Handler

A secure file upload implementation has five layers: size limits enforced before the file is processed, MIME type validation from magic bytes, filename sanitization, storage outside the web root (or in object storage), and - for images specifically - re-encoding through a processing library to strip embedded payloads.

// ✅ GOOD - Secure file upload in Next.js App Router
import { writeFile } from 'fs/promises';
import path from 'path';
import { randomUUID } from 'crypto';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);

// Magic bytes for type validation (much harder to fake than filename/Content-Type)
const MAGIC_BYTES: Record<string, number[]> = {
  'image/jpeg': [0xFF, 0xD8, 0xFF],
  'image/png':  [0x89, 0x50, 0x4E, 0x47],
  'image/webp': [0x52, 0x49, 0x46, 0x46],
};

function detectMimeFromBytes(buffer: Buffer): string | null {
  for (const [mime, magic] of Object.entries(MAGIC_BYTES)) {
    if (!magic.every((byte, i) => buffer[i] === byte)) continue;
    // "RIFF" also starts WAV and AVI files; WebP has "WEBP" at bytes 8-11
    if (mime === 'image/webp' && buffer.toString('ascii', 8, 12) !== 'WEBP') continue;
    return mime;
  }
  return null;
}

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file');

  if (!(file instanceof File)) {
    return Response.json({ error: 'No file provided' }, { status: 400 });
  }

  // 1. Enforce size limit before processing (formData() has already buffered
  //    the request, so also cap the body size at your proxy or platform)
  if (file.size > MAX_FILE_SIZE) {
    return Response.json(
      { error: 'File too large. Maximum 5 MB.' },
      { status: 413 }
    );
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // 2. Validate MIME type from magic bytes (not filename or Content-Type)
  const detectedType = detectMimeFromBytes(buffer);
  if (!detectedType || !ALLOWED_TYPES.has(detectedType)) {
    return Response.json(
      { error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed.' },
      { status: 415 }
    );
  }

  // 3. Generate a safe, random filename - never use the original
  const ext = detectedType.split('/')[1];
  const safeFilename = `${randomUUID()}.${ext}`;

  // 4. Store outside public directory (or use Vercel Blob / S3)
  const uploadDir = path.join(process.cwd(), 'private/uploads');
  await writeFile(path.join(uploadDir, safeFilename), buffer);

  // Return a URL that goes through your own serving endpoint (access-controlled)
  return Response.json({ fileId: safeFilename });
}

For production apps on Vercel, use Vercel Blob (@vercel/blob) or AWS S3 instead of writing to the local filesystem. Object storage handles scaling, CDN delivery, and access control - and it removes the possibility of path traversal to local server files entirely.

Configuring Multer Securely for Express

// ✅ GOOD - Multer with size limits and file filter
import multer from 'multer';
import { randomUUID } from 'crypto';

const EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' };

const upload = multer({
  storage: multer.diskStorage({
    destination: '/var/private/uploads', // Outside web root
    filename: (req, file, cb) => {
      // Never use file.originalname (not even its extension) - generate a safe name
      const ext = EXTENSIONS[file.mimetype] ?? '';
      cb(null, `${randomUUID()}${ext}`);
    },
  }),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB max
    files: 1,                  // Only one file per request
  },
  fileFilter: (req, file, cb) => {
    if (Object.hasOwn(EXTENSIONS, file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type'));
    }
  },
});

// Note: Multer's fileFilter checks the Content-Type header, not magic bytes.
// For defense in depth, also validate magic bytes after upload.

One important caveat: Multer's fileFilter checks the Content-Type header sent by the client - which an attacker can set to anything. For true type validation, read the first few bytes of the saved file and verify against the magic byte signatures after upload.

How to Find Insecure Upload Endpoints in Your Codebase

Search your project for formData.get, multer(, upload.single, writeFile, and createWriteStream. For every file-writing operation, verify that: (1) a size check occurs before or during reading, (2) the file type is validated, (3) the filename is regenerated (not taken from the upload), and (4) the storage location is outside the web root.

VibeDoctor's Vibe Check flags multer, formidable and busboy upload setups that have no file type filter or no size limit, and Python uploads saved from request.files without secure_filename(), with the file and line. It does not inspect request.formData() handlers like the Next.js example above, so check those by hand. Free to sign up.

Also review your Vercel or server configuration. If you have an uploads folder inside your public directory, any file stored there is directly accessible via URL - which is the right setup for intentionally public assets, but a critical vulnerability for user-uploaded content that may contain malicious payloads.

FAQ

Why can't I just check the file extension?

File extensions are user-controlled metadata. An attacker can rename webshell.php to photo.jpg and upload it. The server stores it with the .jpg extension, but if it's served as PHP or accessed directly, the content executes. Magic byte validation reads the actual file content - the first 4-12 bytes that identify the file format. That is far stronger than an extension check, but an attacker can still put a valid image header in front of a payload, which is why re-encoding images is the last layer.

What's the recommended file size limit for profile images?

5 MB is a reasonable limit for unprocessed images from phone cameras. If you're processing images with Sharp or similar (recommended), raise the input limit to 10 MB to allow raw photos, then output optimized WebP at much smaller sizes. Always set both a per-file limit and a per-request limit when accepting multiple files.

Should I use Vercel Blob or AWS S3 for uploads?

Both are good choices. Vercel Blob is the simplest option for apps deployed on Vercel - little setup, CDN-backed, and integrates with Next.js naturally. AWS S3 gives more control, fine-grained IAM permissions, and lifecycle policies. Avoid writing to the local filesystem in serverless environments - file writes don't persist across function invocations on Vercel or similar platforms.

Are SVG files safe to allow for upload?

SVG files are XML and can contain embedded JavaScript via <script> tags. If you allow SVG uploads and serve them with a Content-Type: image/svg+xml header, the browser executes the embedded scripts - stored XSS. Either block SVG entirely or sanitize uploaded SVGs with a library like DOMPurify (server-side) before storage and serving.

What about PDF uploads - are those safe?

PDFs can contain embedded JavaScript, links to external resources, and malicious payloads that exploit PDF reader vulnerabilities. For user-uploaded PDFs, validate the magic bytes (%PDF-), enforce strict size limits, scan with ClamAV if possible, and never auto-execute or render them inline. Store them in private object storage and serve via signed URLs with content-disposition: attachment headers.

Can AI detect insecure file uploads?

AI coding tools like Bolt and Cursor generate working upload endpoints but often skip type validation and size limits. VibeDoctor's SEC-011 check flags multer, formidable and busboy uploads with no type filter or size limit, and Python uploads saved without secure_filename(), with the exact file and line number. Handlers built on request.formData() still need a manual review.

Diagnose your codebase - free

VibeDoctor checks for SEC-011 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →