← All news
security5 min read

File upload security: why extension checks are not enough

By Seaworthy · 7 July 2026

Pile of paper files and documents waiting to be sorted

Photo by Alexander Grey on Unsplash

The check everyone writes

When you ask an AI assistant to add file upload handling, the output looks something like this:

app.post('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).send('No file uploaded');

  if (req.file.mimetype !== 'image/jpeg' && req.file.mimetype !== 'image/png') {
    return res.status(400).send('Invalid file type');
  }

  fs.writeFileSync(`./public/uploads/${req.file.originalname}`, req.file.buffer);
  res.json({ url: `/uploads/${req.file.originalname}` });
});

This passes a code review at a glance. It checks the file type. It rejects things that are not images. What is there to complain about?

Quite a lot, as it turns out.

Where req.file.mimetype comes from

The mimetype property in multer is pulled directly from the Content-Type field of the individual file part in the multipart body. The browser sets this automatically when you pick a file through a form. An attacker's curl command sets it to whatever they want.

curl -F "[email protected];type=image/jpeg" https://yourapp.com/upload

That is it. One flag. The file is a PHP script, the Content-Type says image/jpeg, and your check passes. The server accepts it, stores it, and the attacker figures out where to trigger execution.

req.file.originalname has the same problem. The filename in the multipart body is also client-controlled. Checking .endsWith('.jpg') is the same broken check with different syntax.

What the file actually is

Real format detection reads the file content. Every common binary format has a recognisable signature in its first few bytes, called a magic number. JPEG files start with FF D8 FF. PNG files start with 89 50 4E 47. PHP scripts start with 3C 3F 70 68 70, which is <?php.

The file-type package does this detection for you:

import { fileTypeFromBuffer } from 'file-type';

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).send('No file uploaded');

  const detected = await fileTypeFromBuffer(req.file.buffer);

  const allowed = ['image/jpeg', 'image/png', 'image/webp'];
  if (!detected || !allowed.includes(detected.mime)) {
    return res.status(400).send('File content does not match an allowed image type');
  }

  // safe to proceed
});

This checks what the file is, not what the client claims it is. A PHP file renamed to photo.jpg will not have the right magic bytes and will fail.

One caveat: SVG files are XML, so they have no distinct magic bytes. file-type cannot reliably detect them. If you accept SVGs, you need a separate step: parse the XML and strip or reject any script content. The safest default is to not accept SVG at all unless you have a specific reason to.

Where you store it matters as much as what you check

Even with content detection in place, storage location can be the vulnerability.

A common AI-generated pattern puts uploaded files directly inside public/ and serves them via Express's static middleware:

app.use('/uploads', express.static(path.join(__dirname, 'public/uploads')));

A crafted SVG that slips through your checks will sit in the web root, served with its original content type, and any <script> tag inside it runs in the visitor's browser. That is stored XSS without any server-side code execution involved.

The fix is to store uploads outside the web root entirely. Serve them through a controller that reads from a non-public path and sets headers explicitly:

app.get('/files/:id', async (req, res) => {
  const file = await getFileRecord(req.params.id); // metadata from DB
  if (!file) return res.status(404).send('Not found');

  res.setHeader('Content-Type', file.validatedMimeType);
  res.setHeader('Content-Disposition', `attachment; filename="${file.safeName}"`);
  res.sendFile(path.join(UPLOAD_DIR, file.storedName));
  // UPLOAD_DIR is outside public/, e.g. /var/app/uploads
});

UPLOAD_DIR should be a path that no static route points at. /var/app/uploads, not ./public/uploads.

Path traversal lives here too

While you are fixing the upload handler, check how you construct the saved file path. If req.file.originalname appears anywhere in the path, stop.

A filename of ../../server.js passed through path.join(__dirname, 'public/uploads', filename) will resolve to a path well outside your intended directory. Use path.basename() to strip directory components, or better still, generate your own filename and store the original only in your database:

import { v4 as uuidv4 } from 'uuid';
import path from 'path';

const storedName = `${uuidv4()}${path.extname(req.file.originalname)}`;

The UUID becomes the on-disk name. The original filename is metadata, nothing more.

Putting it together

The insecure pattern is worth making explicit so you can recognise it on sight:

// INSECURE: client-controlled type, path traversal, public storage
if (req.file.mimetype.startsWith('image/')) {
  fs.writeFileSync(`./public/uploads/${req.file.originalname}`, req.file.buffer);
  res.json({ url: `/uploads/${req.file.originalname}` });
}

Three separate problems in four lines. The fix is not a tweak, it is a structural change: validate file content with magic byte detection, generate a server-controlled filename, and store outside the web root.

AI-generated code defaults to the shortest path that works for the happy case. File upload handling is one of the spots where the shortest path is also the most dangerous one.

Seaworthy flags client-controlled MIME type checks and uploads stored under statically served directories as part of its security checks.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode