File Upload Security — The Most Dangerous Feature in Your App
IntroductionDev EnvironmentClientTransportServerAppDataMore

File Upload Security — The Most Dangerous Feature in Your App

Layer 9 in the HTTP Request Journey

You add the ability to upload profile photos to your application. Seems simple — a form, an input type="file", a backend endpoint, save to disk. Fifteen minutes of work.

In reality, you just added one of the most frequently exploited attack vectors to your application. File upload is a feature where even experienced developers make mistakes, because the problem has many layers — and any one of them can be the fatal one.

Why Is File Upload So Dangerous?

With most features in an application, you decide what enters the system — a form has specific fields, values are strings, you can validate them. With file upload, the user decides what they send. You can impose constraints, but ultimately they control the bytes that reach you.

A file can be:

  • Malicious web code (PHP, ASPX, JSP) — if it lands in the web root and the server executes it, the attacker has Remote Code Execution
  • A crafted image containing a hidden script (polyglot file — simultaneously a valid JPEG and valid JS)
  • A malicious ZIP archive (zip bomb — a few kilobytes that expand to tens of gigabytes)
  • An XML file with XXE (XML External Entity) — can exfiltrate files from the server
  • An SVG file with embedded JavaScript — XSS through an "image"

This isn't a list of hypothetical threats. Every one of these vectors has been used in real attacks against web applications.

Mistake #1 — Trusting Content-Type from the Request Header

The most common mistake: validating based on the Content-Type header in the HTTP request.

// BAD — Content-Type comes from the client, easy to forge
if (req.headers['content-type'] !== 'image/jpeg') {
  return res.status(400).send('JPEG only');
}

An attacker can send Content-Type: image/jpeg with a PHP file inside. One header field changed in Burp Suite — and the validation passes.

Content-Type from the request is the client's declaration, not a fact. Never rely on it.

Mistake #2 — Validating Based on File Extension

// BAD — extension is just a string too
const ext = path.extname(file.originalname);
if (ext !== '.jpg' && ext !== '.png') {
  return res.status(400).send('Format not allowed');
}

On Windows, shell.php.jpg can be executed as PHP. On servers with nginx, attacks via misconfiguration are possible (a file image.jpg.php executed as PHP). An extension changes with a single character in a proxy.

How to Check File Type Correctly

The only way to determine what a file really is involves analyzing its content — specifically the magic bytes, the header bytes that identify the file's format.

Every binary format has its signature. Examples:

JPEG: FF D8 FF
PNG:  89 50 4E 47 0D 0A 1A 0A
PDF:  25 50 44 46
ZIP:  50 4B 03 04
PHP:  3C 3F 70 68 70 (<?php)

In Node.js, use the file-type library:

import { fileTypeFromBuffer } from 'file-type';

const buffer = await file.toBuffer();
const type = await fileTypeFromBuffer(buffer);

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

if (!type || !ALLOWED_TYPES.includes(type.mime)) {
  throw new Error('File type not allowed');
}

In Python — the python-magic library:

import magic

mime = magic.from_buffer(file_data, mime=True)
ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/webp'}

if mime not in ALLOWED_TYPES:
    raise ValueError('File type not allowed')

You're checking the actual structure of the file, not what the client claims to be sending.

Where to Save Files — Outside the Web Root

The web root is the directory the web server serves directly. If you save uploaded files to the web root (/var/www/html/uploads/) and the server is configured to execute PHP — the file shell.php is now accessible at https://yourdomain.com/uploads/shell.php and executable.

The rule: files uploaded by users should never land in the web root.

Save them outside the web root and serve them through a dedicated endpoint that:

  1. Checks permissions (does the user have the right to download this file)
  2. Sets appropriate headers
  3. Streams the file, rather than redirecting to a direct file URL
/var/www/html/        ← web root, externally accessible
/var/app/uploads/     ← outside web root, not directly accessible
// Endpoint serving file after permission check
app.get('/files/:fileId', authenticate, async (req, res) => {
  const file = await getFileById(req.params.fileId);
  
  if (file.ownerId !== req.user.id) {
    return res.status(403).send('Access denied');
  }
  
  // Set headers — don't let the browser execute the content
  res.setHeader('Content-Type', file.mimeType);
  res.setHeader('Content-Disposition', 'attachment; filename="' + file.safeName + '"');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  
  // Serve from the safe path
  const safePath = path.join(UPLOAD_DIR, file.storedName);
  res.sendFile(safePath);
});

Path Traversal — Classic File System Attack

Path traversal involves placing the sequence ../ (or its encoded variants) in a filename or parameter, which navigates outside the intended directory.

filename: "../../etc/passwd"

If the backend naively constructs a path from the filename, it may write or read a file outside the allowed directory:

// BAD
const filePath = path.join('/uploads', req.query.filename);
// For filename = "../../etc/passwd" → /etc/passwd

// GOOD — verify that the path is inside the allowed directory
const UPLOAD_DIR = '/var/app/uploads';
const requestedPath = path.resolve(UPLOAD_DIR, req.query.filename);

if (!requestedPath.startsWith(UPLOAD_DIR)) {
  return res.status(400).send('Path not allowed');
}

Never use a filename provided by the user directly to construct a path. Generate your own names (UUID or hash).

Generating Safe Filenames

The original filename from the user is a potential risk. ../../etc/passwd, shell.php, file with spaces and special characters.jpg — each of these names can cause trouble.

Simple solution: don't use the original name as the on-disk name.

import { randomUUID } from 'crypto';
import path from 'path';

function generateSafeFilename(originalName) {
  const ext = path.extname(originalName).toLowerCase();
  return `${randomUUID()}${ext}`;
}

// The original name stays in the database for display purposes
// On disk the file is stored under a UUID
const storedName = generateSafeFilename(file.originalname);
await saveToStorage(buffer, storedName);
await db.files.create({
  storedName,
  originalName: sanitize(file.originalname),
  ownerId: req.user.id
});

File Size Limits

No size limit = potential DoS. A user uploads a 10 GB video — your disk fills up, the server goes down.

Set limits at multiple levels:

// Express + multer
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB
    files: 1                    // max 1 file at a time
  }
});

At the nginx/reverse proxy level, set client_max_body_size as an additional safety net — an oversized request won't even reach your application.

Cloud File Storage — S3 and Signed URLs

If you can, don't store files on your own server — use object storage in the cloud (AWS S3, Google Cloud Storage, Cloudflare R2). You gain:

  • Scaling without managing disks
  • CDN (speed for users)
  • Isolation of files from the application server

For private files, use signed URLs — temporary, cryptographically signed URL addresses that expire after a set time. The user doesn't get a permanent link to the file — they get a link valid for 15 minutes.

// AWS S3 — signed URL valid for 15 minutes
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'eu-central-1' });

async function getFileUrl(key) {
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key
  });
  return await getSignedUrl(s3, command, { expiresIn: 900 }); // 900s = 15 min
}

A public S3 bucket is one of the most common configuration mistakes. Create buckets as private by default.

Malware Scanning

If your application handles documents or files for further processing, consider integrating a malware scanner. AWS offers GuardDuty Malware Protection, Google Cloud — services based on VirusTotal. For your own server, ClamAV is a solid open-source option.

Scanning adds latency, so it makes sense to do it asynchronously: the file goes into quarantine, the scanner verifies, after a positive result the file moves to proper storage.

Checklist for Secure File Upload

  • Verify file type via magic bytes, not Content-Type or extension
  • Generate your own filenames (UUID), don't use original names on disk
  • Save files outside the web root
  • Serve files through an endpoint with permission verification
  • Set Content-Disposition: attachment and X-Content-Type-Options: nosniff
  • Limit file size at application and reverse proxy level
  • For private cloud resources: use signed URLs
  • Validate paths — check that you're in the allowed directory (path.resolve + prefix comparison)
  • Restrict file types to the minimum needed — not "everything except .exe", but "only .jpg, .png, .pdf"

Summary

File upload is among the riskiest features to implement — it gives the outside world a direct channel into your server. Treat every uploaded file as adversarial input by default: assume the type is wrong, the filename is hostile, and the content is untrusted until you've verified it yourself.

Technique Protects against
Magic byte validation (not Content-Type) MIME type spoofing
Generated filenames (UUID) Path traversal, filename injection
Storage outside web root Direct script execution
Serve via endpoint with auth check Unauthorized access to private files
Content-Disposition: attachment + nosniff Browser executing uploaded files as HTML/JS
Size limits at app + proxy level Disk exhaustion attacks
Signed URLs for cloud storage Unauthorized access to S3/GCS objects
Strict allowlist of accepted types Upload of unexpected file types

Getting this right the first time takes a few hours; reworking a production upload system later takes days. The OWASP File Upload Cheat Sheet goes into further depth on each of these controls.


Sources: