How to Secure File Uploads

Share
How-To Guide

How to Secure File Uploads

Prevent attackers from uploading malicious files to your server

TL;DR

TL;DR (25 minutes)

Never trust file extensions or MIME types alone. Validate magic bytes to verify actual file type. Generate random filenames, enforce size limits, store outside webroot, and scan for malware. Use presigned URLs for cloud uploads to avoid files touching your server.

Prerequisites

  • Node.js 18+ with a web framework (Next.js, Express)
  • Cloud storage account (AWS S3, Cloudflare R2, or similar) - recommended
  • Basic understanding of file handling in JavaScript
  • npm or yarn package manager

Why File Upload Security Matters

Insecure file uploads can lead to remote code execution, server compromise, malware distribution, and denial of service. Attackers upload executable files disguised as images, or use path traversal to overwrite system files.

Real Attack Example: An attacker uploads malware.php renamed to profile.jpg. If the server stores it with the original name in a publicly accessible folder, they can execute arbitrary PHP code by visiting /uploads/malware.php.

Step-by-Step Guide

1

Install validation libraries

Install libraries for file type detection and validation:

npm install file-type
npm install uuid
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner  # For S3 uploads
2

Create a file validation utility

Validate files using magic bytes, not just extensions:

// lib/file-validation.ts
import { fileTypeFromBuffer } from 'file-type';

// Define allowed file types with their MIME types and extensions
const ALLOWED_TYPES = {
  // Documents
  'application/pdf': ['.pdf'],
  'application/msword': ['.doc'],
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
  // Images
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/png': ['.png'],
  'image/gif': ['.gif'],
  'image/webp': ['.webp'],
} as const;

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

interface ValidationResult {
  valid: boolean;
  error?: string;
  mimeType?: string;
  extension?: string;
}

export async function validateFile(
  buffer: Buffer,
  originalFilename: string,
  allowedCategories: ('documents' | 'images')[] = ['images']
): Promise<ValidationResult> {
  // Check file size
  if (buffer.length > MAX_FILE_SIZE) {
    return { valid: false, error: `File exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB` };
  }

  if (buffer.length === 0) {
    return { valid: false, error: 'File is empty' };
  }

  // Detect actual file type from magic bytes
  const detectedType = await fileTypeFromBuffer(buffer);

  if (!detectedType) {
    return { valid: false, error: 'Could not determine file type' };
  }

  // Check if detected MIME type is in our allowlist
  const allowedMimes = Object.keys(ALLOWED_TYPES);
  if (!allowedMimes.includes(detectedType.mime)) {
    return { valid: false, error: `File type ${detectedType.mime} is not allowed` };
  }

  // Verify extension matches detected type
  const ext = originalFilename.toLowerCase().slice(originalFilename.lastIndexOf('.'));
  const allowedExts = ALLOWED_TYPES[detectedType.mime as keyof typeof ALLOWED_TYPES];

  if (!allowedExts.includes(ext as any)) {
    return {
      valid: false,
      error: `Extension ${ext} does not match detected type ${detectedType.mime}`,
    };
  }

  return {
    valid: true,
    mimeType: detectedType.mime,
    extension: detectedType.ext,
  };
}
3

Generate safe filenames

Never use user-provided filenames. Generate random names to prevent path traversal and overwrites:

// lib/file-storage.ts
import { v4 as uuidv4 } from 'uuid';
import path from 'path';

export function generateSafeFilename(originalFilename: string, detectedExtension: string): string {
  // Use UUID for the filename
  const uuid = uuidv4();

  // Use the detected extension, not the user-provided one
  return `${uuid}.${detectedExtension}`;
}

export function sanitizeFilename(filename: string): string {
  // Remove path traversal attempts
  const basename = path.basename(filename);

  // Remove any characters that aren't alphanumeric, dash, underscore, or dot
  return basename.replace(/[^a-zA-Z0-9\-_.]/g, '_');
}

// For organizing files by date
export function getStoragePath(userId: string): string {
  const date = new Date();
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');

  return `uploads/${userId}/${year}/${month}`;
}
4

Implement the upload API route

Handle file uploads securely in your API:

// app/api/upload/route.ts
import { validateFile } from '@/lib/file-validation';
import { generateSafeFilename, getStoragePath } from '@/lib/file-storage';
import { uploadToS3 } from '@/lib/s3';
import { getServerSession } from 'next-auth';

export async function POST(request: Request) {
  // Verify authentication
  const session = await getServerSession();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;

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

    // Convert to buffer for validation
    const buffer = Buffer.from(await file.arrayBuffer());

    // Validate file
    const validation = await validateFile(buffer, file.name, ['images']);

    if (!validation.valid) {
      return Response.json({ error: validation.error }, { status: 400 });
    }

    // Generate safe filename and path
    const safeFilename = generateSafeFilename(file.name, validation.extension!);
    const storagePath = getStoragePath(session.user.id);
    const fullPath = `${storagePath}/${safeFilename}`;

    // Upload to cloud storage
    const url = await uploadToS3(buffer, fullPath, validation.mimeType!);

    // Store metadata in database
    await db.file.create({
      data: {
        userId: session.user.id,
        originalName: file.name,
        storedName: safeFilename,
        path: fullPath,
        mimeType: validation.mimeType!,
        size: buffer.length,
        url,
      },
    });

    return Response.json({ url, filename: safeFilename });
  } catch (error) {
    console.error('Upload error:', error);
    return Response.json({ error: 'Upload failed' }, { status: 500 });
  }
}
5

For better security and scalability, let clients upload directly to cloud storage:

// lib/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function getPresignedUploadUrl(
  key: string,
  contentType: string,
  maxSizeBytes: number
): Promise<{ url: string; fields: Record<string, string> }> {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(s3, command, {
    expiresIn: 300, // 5 minutes
  });

  return { url, fields: {} };
}

// app/api/upload/presign/route.ts
export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { filename, contentType } = await request.json();

  // Validate content type before generating presigned URL
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
  if (!allowedTypes.includes(contentType)) {
    return Response.json({ error: 'File type not allowed' }, { status: 400 });
  }

  const key = `uploads/${session.user.id}/${uuidv4()}.${contentType.split('/')[1]}`;
  const { url } = await getPresignedUploadUrl(key, contentType, 10 * 1024 * 1024);

  return Response.json({ uploadUrl: url, key });
}
6

Add malware scanning

Integrate virus scanning for uploaded files:

// lib/malware-scan.ts
import { ClamScan } from 'clamscan';

// Option 1: Self-hosted ClamAV
const clamscan = new ClamScan({
  clamdscan: {
    socket: '/var/run/clamav/clamd.ctl',
  },
});

export async function scanForMalware(buffer: Buffer): Promise<boolean> {
  try {
    const { isInfected, viruses } = await clamscan.scanBuffer(buffer);

    if (isInfected) {
      console.warn('Malware detected:', viruses);
      return false; // File is infected
    }

    return true; // File is clean
  } catch (error) {
    console.error('Malware scan failed:', error);
    // Fail closed - reject file if scan fails
    return false;
  }
}

// Option 2: Cloud-based scanning (VirusTotal API)
export async function scanWithVirusTotal(buffer: Buffer): Promise<boolean> {
  const formData = new FormData();
  formData.append('file', new Blob([buffer]));

  const response = await fetch('https://www.virustotal.com/api/v3/files', {
    method: 'POST',
    headers: {
      'x-apikey': process.env.VIRUSTOTAL_API_KEY!,
    },
    body: formData,
  });

  const data = await response.json();
  // Check analysis results...
  return data.data.attributes.last_analysis_stats.malicious === 0;
}

Security Checklist

  • Validate MIME type using magic bytes, not file extension
  • Use an allowlist of permitted file types
  • Generate random filenames (UUIDs) instead of using user input
  • Enforce file size limits on both client and server
  • Store files outside the webroot or use cloud storage
  • Set proper Content-Type and Content-Disposition headers when serving
  • Scan files for malware before storing
  • Use presigned URLs for direct-to-cloud uploads
  • Implement rate limiting on upload endpoints
  • Log all upload attempts for security monitoring

How to Verify It Worked

Test your upload security with these scenarios:

// Test cases for file upload security

// 1. Test extension mismatch (should reject)
// Create a PHP file, rename to .jpg
// Upload should fail because magic bytes don't match

// 2. Test path traversal (should sanitize)
const maliciousFilename = '../../../etc/passwd.jpg';
// Should be stored as random UUID, not with path traversal

// 3. Test oversized file (should reject)
// Try uploading a file larger than MAX_FILE_SIZE
// Should return 400 error

// 4. Test double extension (should handle correctly)
const doubleExt = 'image.php.jpg';
// Magic bytes should determine actual type

// Automated test
async function testUploadSecurity() {
  // Test 1: Valid image
  const validImage = await fetch('/test-image.jpg').then(r => r.arrayBuffer());
  const result1 = await uploadFile(Buffer.from(validImage), 'test.jpg');
  console.assert(result1.success, 'Valid image should upload');

  // Test 2: PHP disguised as image
  const phpContent = '<?php echo "hacked"; ?>';
  const result2 = await uploadFile(Buffer.from(phpContent), 'test.jpg');
  console.assert(!result2.success, 'PHP file should be rejected');

  // Test 3: Path traversal filename
  const result3 = await uploadFile(Buffer.from(validImage), '../../../etc/passwd');
  console.assert(
    !result3.storedName.includes('..'),
    'Path traversal should be sanitized'
  );
}

Pro Tip: Use OWASP's file upload testing guide to verify your implementation. Test with polyglot files that are valid in multiple formats.

Common Errors and Troubleshooting

Error: file-type returns undefined

// Problem: File is too small or has no magic bytes
const detectedType = await fileTypeFromBuffer(buffer);
// Returns undefined for plain text, empty files, etc.

// Solution: Handle undefined case explicitly
if (!detectedType) {
  // For text files, validate manually or reject
  if (allowPlainText) {
    return { valid: true, mimeType: 'text/plain', extension: 'txt' };
  }
  return { valid: false, error: 'Unknown file type' };
}

Error: Presigned URL upload fails with CORS error

// Problem: S3 bucket CORS not configured

// Solution: Add CORS configuration to S3 bucket
const corsConfig = {
  CORSRules: [
    {
      AllowedHeaders: ['*'],
      AllowedMethods: ['PUT', 'POST'],
      AllowedOrigins: ['https://yourdomain.com'],
      ExposeHeaders: ['ETag'],
      MaxAgeSeconds: 3600,
    },
  ],
};

Error: Large files timeout during upload

// Problem: Server timeout on large file processing

// Solution: Use chunked uploads or direct-to-cloud
// For server-handled uploads, increase timeout:
export const config = {
  api: {
    bodyParser: {
      sizeLimit: '50mb',
    },
    responseLimit: false,
  },
};

// Better: Use presigned URLs for large files
// Client uploads directly to S3, skipping your server

Error: Files served with wrong Content-Type

// Problem: Browser executes uploaded HTML/JS

// Solution: Set proper headers when serving files
app.get('/files/:id', async (req, res) => {
  const file = await db.file.findUnique({ where: { id: req.params.id } });

  res.setHeader('Content-Type', file.mimeType);
  res.setHeader('Content-Disposition', `attachment; filename="${file.originalName}"`);
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // Stream file from storage
});

Frequently Asked Questions

Can I trust the Content-Type header from the browser?

No. The Content-Type header can be easily spoofed. Always verify the actual file contents using magic bytes with a library like file-type.

Where should I store uploaded files?

Use cloud storage (S3, R2, GCS) whenever possible. If you must store locally, use a directory outside your webroot that can't be directly accessed via URL. Never store in public/ folders.

How do I handle large file uploads?

Use presigned URLs for direct-to-cloud uploads. The client uploads directly to S3/R2, bypassing your server entirely. This is more scalable and secure.

Should I resize images on upload?

Yes, for both security and performance. Resizing strips potentially malicious metadata and reduces storage costs. See our Image Upload Security guide for details.

Do I need malware scanning?

If users can upload files that others will download (file sharing, documents), yes. For profile images that you process/resize, the risk is lower but scanning is still recommended for high-security applications.

How-To Guides

How to Secure File Uploads