Secure File Upload Implementation: Validation, Storage, and Scan Patterns | SoniNow Blog

Limited TimeLearn More

file uploadsecurityvalidationmalware scanningstorage

Secure File Upload Implementation: Validation, Storage, and Scan Patterns

Published

2026-06-23

Read Time

6 mins

Secure File Upload Implementation: Validation, Storage, and Scan Patterns

File upload functionality is one of the most frequently exploited features in web applications. An insecure upload endpoint can lead to remote code execution, malware distribution, data exfiltration, and server compromise. Every upload must be treated as a potential attack.

Multi-Layer File Validation

Relying on a single validation technique — such as checking the Content-Type header — is insufficient because HTTP headers can be spoofed. A defense-in-depth approach validates at multiple layers.

import { fileTypeFromBuffer } from 'file-type';
import crypto from 'crypto';

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

async function validateUpload(
  fileBuffer: Buffer,
  originalName: string
): Promise<FileValidationResult> {
  // Layer 1: File size check
  const MAX_SIZE = 15 * 1024 * 1024; // 15 MB
  if (fileBuffer.length > MAX_SIZE) {
    return { valid: false, mimeType: '', extension: '', error: 'File too large' };
  }

  // Layer 2: Magic bytes verification (ignores Content-Type header)
  const fileType = await fileTypeFromBuffer(fileBuffer);
  if (!fileType) {
    return { valid: false, mimeType: '', extension: '', error: 'Unknown file type' };
  }

  const ALLOWED_TYPES = new Map([
    ['image/jpeg', ['.jpg', '.jpeg']],
    ['image/png', ['.png']],
    ['image/webp', ['.webp']],
    ['application/pdf', ['.pdf']],
    ['application/zip', ['.zip']],
    ['text/csv', ['.csv']],
  ]);

  if (!ALLOWED_TYPES.has(fileType.mime)) {
    return { valid: false, mimeType: '', extension: '', error: 'File type not allowed' };
  }

  // Layer 3: Extension match
  const ext = path.extname(originalName).toLowerCase();
  const validExts = ALLOWED_TYPES.get(fileType.mime)!;
  if (!validExts.includes(ext)) {
    return { valid: false, mimeType: '', extension: '', error: 'Extension mismatch' };
  }

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

Magic byte verification reads the file's header bytes to determine the actual content type, which attackers cannot spoof. Combine this with extension whitelisting and reject files where the declared type, detected type, and extension disagree.

Preventing Path Traversal

Path traversal attacks allow an attacker to write files to arbitrary locations on the filesystem by including ../ sequences in the filename. Sanitize filenames strictly.

function sanitizeFilename(filename: string): string {
  // Remove path separators and parent directory references
  let safe = filename
    .replace(/[^a-zA-Z0-9._-]/g, '_')  // Replace unsafe chars
    .replace(/\.{2,}/g, '.')            // Collapse multiple dots
    .replace(/^\.+/g, '')               // Remove leading dots
    .substring(0, 255);                 // Limit length

  // Generate a UUID-based filename and store the original separately
  return `${crypto.randomUUID()}_${safe}`;
}

// Store files outside the web root
const UPLOAD_DIR = path.resolve('/var/data/uploads');
const safeFilename = sanitizeFilename(originalName);
const fullPath = path.join(UPLOAD_DIR, safeFilename);

// Verify the resolved path is within the upload directory
if (!fullPath.startsWith(UPLOAD_DIR + path.sep)) {
  throw new Error('Path traversal detected');
}

Never use user-supplied filenames for server-side paths. Generate a random filename on the server and store the original name only in the database alongside the file record. Use path.resolve() followed by a prefix check to prevent traversal even through encoding tricks.

Secure Storage Patterns

Files should be stored outside the web root so they are not directly accessible via URL. For applications using cloud storage, configure bucket policies carefully.

# AWS S3: Secure bucket for file uploads
resource "aws_s3_bucket" "uploads" {
  bucket = "soninow-uploads"
}

# Block public access entirely — serve through signed URLs
resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket = aws_s3_bucket.uploads.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Pre-signed URL generation for secure downloads
resource "aws_iam_policy" "generate_presigned_url" {
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Action = ["s3:GetObject"],
      Resource = "${aws_s3_bucket.uploads.arn}/*",
    }],
  })
}

Serve uploaded files through a proxy endpoint that validates authentication before returning the file content. For S3, generate pre-signed URLs with short expiration windows (5-15 minutes). For local storage, use an internal endpoint that reads the file and streams it to authenticated users.

Malware Scanning

Every uploaded file should be scanned for malware before it becomes accessible. Integrate with antivirus scanners like ClamAV at the upload level.

import { createScanner } from 'clamscan';

const scanner = await createScanner({
  clamdscan: {
    socket: '/var/run/clamav/clamd.sock',
    timeout: 30000,
  },
});

async function scanFile(filePath: string): Promise<boolean> {
  try {
    const { isInfected, viruses } = await scanner.isInfected(filePath);
    if (isInfected) {
      await fs.unlink(filePath); // Delete infected file immediately
      logger.warn('Malware detected', { filePath, viruses });
      return false;
    }
    return true;
  } catch (error) {
    logger.error('Scan error — quarantining file', { filePath, error });
    // Move to quarantine for manual review
    await fs.rename(filePath, filePath + '.quarantine');
    return false;
  }
}

For cloud storage, use the provider's malware scanning integration — AWS GuardDuty Malware Protection for S3 or Google Cloud Security Command Center. Scan files asynchronously after upload and mark them as unavailable until the scan completes.

Image Processing and Re-encoding

For image uploads, reprocess the image server-side using a secure library. This strips EXIF metadata (which can contain GPS coordinates), removes hidden data, and prevents ImageMagick/GD-based attacks.

import sharp from 'sharp';

async function processImage(filePath: string, mimeType: string): Promise<Buffer> {
  const image = sharp(filePath);

  // Re-encode to safe format — strips metadata and potential payload
  const output = await image
    .resize(2048, 2048, { fit: 'inside', withoutEnlargement: true })
    .withMetadata(false) // Strip all metadata
    .toFormat(mimeType === 'image/png' ? 'png' : 'jpeg', {
      quality: 85,
    })
    .toBuffer();

  return output;
}

Re-encoding destroys any steganographic payloads or embedded EXIF data in the original file. Set maximum output dimensions to prevent denial-of-service through decompression bombs.

Content-Disposition and Serving

When serving uploaded files to users, set headers that prevent execution and enforce download behavior:

app.get('/files/:id', async (req, res) => {
  const file = await FileModel.findById(req.params.id);
  if (!file) return res.status(404).end();

  const filePath = path.join(UPLOAD_DIR, file.storedName);

  res.set({
    'Content-Type': file.mimeType,
    'Content-Disposition': `attachment; filename="${file.originalName}"`,
    'Content-Length': file.size.toString(),
    'X-Content-Type-Options': 'nosniff',
    'Cache-Control': 'private, max-age=3600',
  });

  const readStream = fs.createReadStream(filePath);
  readStream.pipe(res);
});

The Content-Disposition: attachment header forces download rather than in-browser rendering, preventing HTML files from executing scripts. The X-Content-Type-Options: nosniff header prevents MIME-type sniffing.

Upload Rate Limiting

File upload endpoints are targets for denial-of-service attacks through large file uploads. Implement rate limiting specifically for upload endpoints:

const uploadRateLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  message: 'Upload limit exceeded. Try again later.',
  keyGenerator: (req) => req.user?.id || req.ip,
});

app.post('/api/upload', uploadRateLimiter, uploadMiddleware, uploadHandler);

Parallelize upload limits across server instances using a shared Redis store. Track total upload volume per user per day and enforce quotas that align with your application's legitimate usage patterns.

Secure file upload requires careful implementation across multiple layers of validation and storage. Our <a href="/services/web-development">web development services</a> include secure file handling as a standard component. Contact SoniNow to review your file upload implementation.