Securely handling file uploads is critical in modern web development. As AJAX file uploads become increasingly prevalent, protecting your application from potential vulnerabilities is essential. In this post, we explore modern best practices and techniques—including using the Fetch API, chunked uploads, and comprehensive error handling—to build secure file upload systems.

Understanding Ajax file uploads

AJAX (Asynchronous JavaScript and XML) enables web applications to send and receive data from the server asynchronously without refreshing the page. When implementing AJAX file uploads, it is important to address security considerations on both the client and server sides.

Browser compatibility

Modern browsers provide robust support for file uploads through various APIs:

  • The FormData API is available in all modern browsers.
  • The Fetch API is the recommended approach over legacy techniques.
  • The File API offers advanced file handling capabilities.
  • XMLHttpRequest remains supported for legacy scenarios but is generally discouraged in favor of Fetch.

For more details, refer to the MDN documentation on FormData and Fetch API.

Implementing modern file uploads

Using the fetch API with async/await

The Fetch API, combined with async/await, offers a clean method for uploading files:

const uploadFile = async (file) => {
  const formData = new FormData()
  formData.append('file', file)

  try {
    const response = await fetch('/upload', {
      method: 'POST',
      body: formData,
    })
    if (!response.ok) {
      throw new Error(`Upload failed: ${response.status}`)
    }
    return await response.json()
  } catch (error) {
    console.error('Upload error:', error)
    throw error
  }
}

Tracking upload progress

Using the Fetch API with streams allows for progress tracking during file uploads:

const uploadWithProgress = async (file) => {
  const formData = new FormData()
  formData.append('file', file)

  try {
    const response = await fetch('/upload', {
      method: 'POST',
      body: formData,
    })

    const reader = response.body.getReader()
    const contentLength = +response.headers.get('Content-Length')

    let receivedLength = 0
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      receivedLength += value.length
      const progress = (receivedLength / contentLength) * 100
      console.log(`Progress: ${progress.toFixed(2)}%`)
    }

    return await response.json()
  } catch (error) {
    console.error('Upload error:', error)
    throw error
  }
}

Handling chunked uploads

For large files, breaking the upload into smaller chunks improves reliability in unstable network conditions:

const CHUNK_SIZE = 1024 * 1024 // 1MB chunks

const uploadLargeFile = async (file) => {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
  const fileId = Date.now().toString(36) // Simple unique ID

  for (let chunk = 0; chunk < totalChunks; chunk++) {
    const start = chunk * CHUNK_SIZE
    const end = Math.min(start + CHUNK_SIZE, file.size)
    const fileChunk = file.slice(start, end)
    const formData = new FormData()
    formData.append('chunk', fileChunk)
    formData.append('fileId', fileId)
    formData.append('chunkIndex', chunk)
    formData.append('totalChunks', totalChunks)

    try {
      await fetch('/upload/chunk', {
        method: 'POST',
        body: formData,
      })
    } catch (error) {
      console.error(`Chunk ${chunk} failed:`, error)
      throw error
    }
  }

  // Finalize the upload
  return fetch('/upload/complete', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ fileId }),
  })
}

Handling multiple file uploads

Uploading multiple files concurrently can improve performance. By using the input's multiple attribute and modern JavaScript, you can efficiently handle several uploads:

const uploadMultipleFiles = async (files) => {
  const uploadPromises = Array.from(files).map((file) => {
    const formData = new FormData()
    formData.append('file', file)
    return fetch('/upload', { method: 'POST', body: formData })
      .then((response) => {
        if (!response.ok) {
          throw new Error(`Upload failed for ${file.name}`)
        }
        return response.json()
      })
      .catch((error) => {
        console.error(`Error uploading ${file.name}:`, error)
        throw error
      })
  })
  return Promise.all(uploadPromises)
}

Drag and drop file upload

Enhance user experience by implementing drag-and-drop file uploads. The following example establishes a drop zone that responds to drag events:

const dropZone = document.getElementById('drop-zone')

dropZone.addEventListener('dragover', (e) => {
  e.preventDefault()
  dropZone.classList.add('highlight')
})

dropZone.addEventListener('dragleave', () => {
  dropZone.classList.remove('highlight')
})

dropZone.addEventListener('drop', async (e) => {
  e.preventDefault()
  dropZone.classList.remove('highlight')
  const files = e.dataTransfer.files
  try {
    const results = await uploadMultipleFiles(files)
    console.log('Files uploaded:', results)
  } catch (error) {
    console.error('Error during drag-and-drop upload:', error)
  }
})

Client-side file type validation

Before uploading, validate file types using the File API to provide immediate feedback to users:

const validateFileType = (file) => {
  const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
  if (!allowedTypes.includes(file.type)) {
    alert(`Invalid file type: ${file.type}`)
    return false
  }
  return true
}

const handleFileInput = (event) => {
  const files = event.target.files
  Array.from(files).forEach((file) => {
    if (validateFileType(file)) {
      uploadFile(file)
    }
  })
}

document.getElementById('file-input').addEventListener('change', handleFileInput)

Server-side implementation

Below is a Node.js example using Express and Multer for handling file uploads securely. For this example, we use Multer 1.4.5-lts.1 (latest stable as of 2024). Install it via: npm install multer@1.4.5-lts.1

const express = require('express')
const multer = require('multer') // using Multer 1.4.5-lts.1
const path = require('path')
const crypto = require('crypto')

const app = express()

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/')
  },
  filename: (req, file, cb) => {
    // Generate a secure random filename
    crypto.randomBytes(16, (err, raw) => {
      if (err) return cb(err)
      cb(null, raw.toString('hex') + path.extname(file.originalname))
    })
  },
})

const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true)
  } else {
    cb(new Error('Invalid file type'), false)
  }
}

const upload = multer({
  storage,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB
    files: 1,
  },
  fileFilter,
})

const rateLimit = require('express-rate-limit')
const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 uploads per window
})

app.post('/upload', uploadLimiter, upload.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      throw new Error('No file uploaded')
    }
    // Implement virus scanning here if needed
    res.json({
      message: 'File uploaded successfully',
      filename: req.file.filename,
    })
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
})

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({
    error: 'Upload failed',
    details: err.message,
  })
})

Security best practices

Protect your file upload system by implementing robust security measures:

  1. Set a strict Content Security Policy (CSP):
app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'")
  next()
})
  1. Validate uploaded files strictly:
const validateFile = (file) => {
  const maxSize = 5 * 1024 * 1024 // 5MB
  const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
  if (file.size > maxSize) {
    throw new Error('File too large')
  }
  if (!allowedTypes.includes(file.type)) {
    throw new Error('Invalid file type')
  }
}
  1. Configure secure storage:

    • Store files outside the web root.
    • Use randomized filenames.
    • Set proper file permissions.
    • Consider cloud storage options for scalability.
  2. Implement rate limiting to curb abuse.

  3. Enforce HTTPS to encrypt file transfers.

  4. Use signed URLs for secure, direct-to-storage uploads.

  5. Integrate virus scanning using reliable cloud-based solutions.

Error handling and recovery

Robust error handling improves user experience. Implement retry logic for transient network failures:

const uploadWithRetry = async (file, maxRetries = 3) => {
  let attempts = 0
  while (attempts < maxRetries) {
    try {
      const result = await uploadFile(file)
      return result
    } catch (error) {
      attempts++
      if (attempts === maxRetries) {
        throw new Error(`Upload failed after ${maxRetries} attempts`)
      }
      // Exponential backoff before retrying
      await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 1000))
    }
  }
}

Conclusion

Secure file uploads require thorough client- and server-side measures. By implementing modern techniques for validation, error handling, and efficient uploads, you can build a robust system that mitigates common vulnerabilities.

For an advanced solution with extensive features, consider using Uppy (currently at version 4.x), which offers TypeScript support, Google Photos integration, React hooks, and parallel uploads.