File Upload Security: Preventing Unrestricted File Upload Attacks

Type: Software Reference Confidence: 0.95 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

#VulnerabilityRiskVulnerable CodeSecure Code
1Unrestricted upload (CWE-434)Critical -- RCE via web shellNo extension/type checkAllowlist extensions + magic byte validation
2MIME type spoofingHigh -- bypass content filtersif (file.mimetype === 'image/png')Read first 512+ bytes, validate magic signature
3Path traversal via filenameHigh -- arbitrary file writepath.join(uploadDir, file.originalname)Generate UUID filename, strip all path components
4Stored XSS via SVG/HTML uploadHigh -- XSS on same originAllow SVG/HTML uploads, serve inlineServe with Content-Disposition: attachment + CSP
5Malware uploadHigh -- infect other usersNo scanningScan with ClamAV or cloud AV API before storage
6Zip bomb / decompression bombMedium -- DoS, disk exhaustionExtract without size checksEnforce compression ratio < 100:1, max extracted size
7Image bomb (pixel flood)Medium -- DoS, memory exhaustionProcess image without dimension checkEnforce max pixel dimensions (e.g., 10000x10000)
8Double extension bypassHigh -- execute disguised fileBlock .php but allow file.php.jpgCheck final extension only after stripping all dots
9Null byte injectionHigh -- truncate filenamefile.php%00.jpg passes extension checkReject filenames containing null bytes or non-printable chars
10Oversized upload DoSMedium -- disk/bandwidth exhaustionNo file size limitEnforce max size at reverse proxy AND application level

Decision Tree

START: What type of file upload does your application accept?
├── Images only (JPEG, PNG, GIF, WebP)?
│   ├── YES → Allowlist image extensions + validate magic bytes + re-encode image (strip metadata)
│   └── NO ↓
├── Documents (PDF, DOCX, XLSX)?
│   ├── YES → Allowlist doc extensions + magic bytes + Content Disarm & Reconstruct (CDR) or AV scan
│   └── NO ↓
├── Archives (ZIP, TAR, GZ)?
│   ├── YES → Allowlist archive extensions + magic bytes + decompression ratio < 100:1 + max extracted size
│   └── NO ↓
├── Executable or script files needed?
│   ├── YES → STOP: reconsider architecture. If unavoidable: sandbox + AV scan + never serve from web root
│   └── NO ↓
├── Mixed/unknown file types?
│   ├── YES → Allowlist only required extensions + magic bytes + AV scan + Content-Disposition: attachment
│   └── NO ↓
└── DEFAULT → Allowlist extensions + magic byte validation + UUID filename + store outside web root

Step-by-Step Guide

1. Configure server-side file size limits

Set maximum upload size at both the reverse proxy and application level. This is your first line of defense against DoS via oversized uploads. [src1]

# Nginx: limit upload size to 10MB
client_max_body_size 10m;
// Express/Multer: limit to 10MB
const upload = multer({
  limits: { fileSize: 10 * 1024 * 1024 }
});

Verify: Upload a file larger than the limit -- should receive HTTP 413 or framework-specific rejection.

2. Implement extension allowlisting

Only permit file extensions that your application specifically needs. Use an allowlist, never a blocklist. [src1]

ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf'}
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
    raise ValidationError(f'File type {ext} not allowed')

Verify: Attempt to upload a .php, .exe, or .html file -- should be rejected.

3. Validate file content with magic bytes

The Content-Type header and file extension are user-controlled. Read the actual file bytes to verify content type. [src2]

import magic
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ALLOWED_MIMES:
    raise ValidationError(f'File content type {mime} not allowed')

Verify: Rename a .php file to .jpg and upload -- magic byte check should reject it.

4. Generate random filenames and store outside web root

Never use the user-supplied filename for storage. Generate a UUID and store files outside the web-accessible directory. [src1]

import uuid, os
safe_name = f'{uuid.uuid4().hex}{validated_ext}'
dest = os.path.join('/var/uploads', safe_name)  # Outside web root

Verify: Check that stored files have UUID names, reside outside the web root, and the directory has no execute permission.

5. Serve uploaded files safely

Never serve uploaded files directly from the filesystem. Use a controller that sets security headers. [src3]

response['Content-Disposition'] = f'attachment; filename="{original_name}"'
response['X-Content-Type-Options'] = 'nosniff'
response['Content-Security-Policy'] = "default-src 'none'"

Verify: Access an uploaded file URL -- should download (not render inline) with correct security headers.

6. Scan for malware (production environments)

For untrusted user uploads, integrate virus scanning before persistence. [src5]

clamscan --max-filesize=10M uploaded_file.pdf

Verify: Upload an EICAR test file -- should be detected and rejected.

Code Examples

Python/Django: Complete Secure Upload Handler

import os, uuid, magic
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.http import require_POST

ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']
ALLOWED_MIMES = {
    'image/jpeg', 'image/png', 'image/gif',
    'image/webp', 'application/pdf'
}
MAX_SIZE = 10 * 1024 * 1024  # 10MB
UPLOAD_DIR = '/var/uploads'   # Outside web root

@require_POST
def upload(request):
    file = request.FILES.get('file')
    if not file:
        return JsonResponse({'error': 'No file'}, status=400)
    if file.size > MAX_SIZE:
        return JsonResponse({'error': 'File too large'}, status=413)
    ext = os.path.splitext(file.name)[1].lower()
    if ext.lstrip('.') not in ALLOWED_EXT:
        return JsonResponse({'error': 'Type not allowed'}, status=400)
    mime = magic.from_buffer(file.read(2048), mime=True)
    file.seek(0)
    if mime not in ALLOWED_MIMES:
        return JsonResponse({'error': f'Invalid content: {mime}'}, status=400)
    safe_name = f'{uuid.uuid4().hex}{ext}'
    dest = os.path.join(UPLOAD_DIR, safe_name)
    with open(dest, 'wb') as f:
        for chunk in file.chunks():
            f.write(chunk)
    return JsonResponse({'id': safe_name}, status=201)

Node.js/Express: Multer with Magic Byte Validation

import express from 'express';
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
import { randomUUID } from 'crypto';
import { readFile, unlink, rename } from 'fs/promises';
import path from 'path';

const UPLOAD_DIR = '/var/uploads';
const ALLOWED_MIMES = new Set([
  'image/jpeg', 'image/png', 'image/gif',
  'image/webp', 'application/pdf'
]);
const upload = multer({
  dest: '/tmp/uploads',
  limits: { fileSize: 10 * 1024 * 1024 }
});
const app = express();

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file' });
  try {
    const buffer = await readFile(req.file.path);
    const type = await fileTypeFromBuffer(buffer);
    if (!type || !ALLOWED_MIMES.has(type.mime)) {
      await unlink(req.file.path);
      return res.status(400).json({ error: 'Invalid file content' });
    }
    const safeName = `${randomUUID()}.${type.ext}`;
    await rename(req.file.path, path.join(UPLOAD_DIR, safeName));
    res.status(201).json({ id: safeName });
  } catch (err) {
    if (req.file?.path) await unlink(req.file.path).catch(() => {});
    res.status(500).json({ error: 'Upload failed' });
  }
});

Go: Secure File Upload Handler

package main

import (
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
)

const (
    maxUploadSize = 10 << 20 // 10MB
    uploadDir     = "/var/uploads"
)

var allowedMIMEs = map[string]string{
    "image/jpeg":      ".jpg",
    "image/png":       ".png",
    "image/gif":       ".gif",
    "image/webp":      ".webp",
    "application/pdf": ".pdf",
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
    if err := r.ParseMultipartForm(maxUploadSize); err != nil {
        http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
        return
    }
    file, _, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "Invalid file", http.StatusBadRequest)
        return
    }
    defer file.Close()
    buf := make([]byte, 512)
    n, _ := file.Read(buf)
    mime := http.DetectContentType(buf[:n])
    ext, ok := allowedMIMEs[mime]
    if !ok {
        http.Error(w, fmt.Sprintf("Type %s not allowed", mime), http.StatusBadRequest)
        return
    }
    file.Seek(0, io.SeekStart)
    randBytes := make([]byte, 16)
    rand.Read(randBytes)
    safeName := hex.EncodeToString(randBytes) + ext
    dst, err := os.Create(filepath.Join(uploadDir, safeName))
    if err != nil {
        http.Error(w, "Storage error", http.StatusInternalServerError)
        return
    }
    defer dst.Close()
    io.Copy(dst, file)
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{"id": safeName})
}

Anti-Patterns

Wrong: Trusting file extension only

# BAD -- extension is user-controlled and trivially spoofed
def upload(request):
    file = request.FILES['file']
    if file.name.endswith('.jpg'):  # Attacker renames malware.php to malware.jpg
        file.save('/var/www/uploads/' + file.name)  # Stored in web root!

Correct: Extension allowlist + magic bytes + UUID filename

# GOOD -- multi-layer validation with safe storage
ext = os.path.splitext(file.name)[1].lower()
if ext not in ALLOWED_EXT:
    raise ValidationError('Extension not allowed')
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ALLOWED_MIMES:
    raise ValidationError('Content type mismatch')
safe_name = f'{uuid.uuid4().hex}{ext}'
dest = os.path.join('/var/uploads', safe_name)

Wrong: Storing uploads in the web root

// BAD -- files in web root can be executed by the web server
const upload = multer({ dest: 'public/uploads/' });
// Attacker uploads shell.php -> accessible at https://example.com/uploads/shell.php

Correct: Store outside web root, serve via controller

// GOOD -- files stored outside web root, served through authenticated route
const upload = multer({ dest: '/var/uploads/' });

app.get('/files/:id', authenticate, (req, res) => {
  res.set('Content-Disposition', 'attachment');
  res.set('X-Content-Type-Options', 'nosniff');
  res.sendFile(path.join('/var/uploads', req.params.id));
});

Wrong: No file size limits

// BAD -- no size limit allows DoS via multi-GB uploads
const upload = multer({ dest: '/tmp/uploads' });
app.post('/upload', upload.single('file'), handler);

Correct: Enforce size limits at multiple layers

// GOOD -- size limit at Multer + Nginx/reverse proxy
const upload = multer({
  dest: '/tmp/uploads',
  limits: { fileSize: 10 * 1024 * 1024 }  // 10MB
});
// Also set in Nginx: client_max_body_size 10m;

Wrong: Using user-supplied filename for storage

// BAD -- path traversal: filename could be "../../etc/passwd"
destPath := filepath.Join(uploadDir, header.Filename)
dst, _ := os.Create(destPath)

Correct: Generate random filename, ignore user input

// GOOD -- UUID filename prevents path traversal and collisions
randBytes := make([]byte, 16)
rand.Read(randBytes)
safeName := hex.EncodeToString(randBytes) + validatedExt
destPath := filepath.Join(uploadDir, safeName)

Common Pitfalls

Diagnostic Commands

# Check file's real MIME type via magic bytes
file --mime-type uploaded_file.jpg

# Check file magic signature (first 16 bytes as hex)
xxd -l 16 uploaded_file.jpg

# Known magic bytes: JPEG=FF D8 FF, PNG=89 50 4E 47, PDF=%PDF, ZIP=50 4B 03 04

# Scan file for malware with ClamAV
clamscan uploaded_file.pdf

# Check upload directory permissions (should NOT have execute)
ls -la /var/uploads/

# Check Nginx upload size limit
nginx -T 2>/dev/null | grep client_max_body_size

# Find files with double extensions in upload directory
find /var/uploads -name '*.*.*' -type f

# Check for executable files in upload directory
find /var/uploads -type f -executable

Version History & Compatibility

Standard/ToolVersionStatusKey Feature
OWASP File Upload Cheat Sheet2024CurrentComprehensive upload security guidance
CWE-4344.19CurrentTop-25 CWE weakness classification
python-magic0.4.xCurrentlibmagic bindings for Python
file-type (npm)19.xCurrentMagic byte detection (ESM only)
file-type (npm)16.xMaintainedCommonJS support (legacy)
multer (npm)1.4.xCurrentExpress multipart middleware
Go net/http1.22+CurrentDetectContentType() built-in (512 bytes)
gabriel-vasile/mimetype1.4.xCurrentExtended magic byte detection for Go
ClamAV1.3.xCurrentOpen-source antivirus scanning

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Application accepts file uploads from any userApplication only stores files generated server-sideStandard filesystem security
Uploaded files are served back to other usersFiles are only processed server-side and never servedSimpler validation may suffice
Upload includes images, documents, or archivesAPI only accepts JSON/text payloads (no binary files)Input validation patterns
Running a multi-tenant platform with untrusted usersSingle-user admin tool with trusted operatorsReduced validation + logging
Files are processed (image resize, document parse)Files are stored as opaque blobs and never openedHash verification + size limits only

Important Caveats

Related Units