14Data

File Upload

File Upload Security — Instruction 14

Coverage

CWE-434 (Unrestricted Upload), CWE-22 (Path Traversal / Zip Slip), CWE-732 OWASP A05:2021


File Upload Checks

1. MIME Type Validation (Server-Side)

// 🔴 Trust Content-Type header from client (easily forged)
if (req.headers['content-type'] === 'image/jpeg') { accept() }

// 🔴 Trust file extension only
if (file.name.endsWith('.jpg')) { accept() }

// 🟢 Read actual file bytes (magic bytes) to detect real type
import { fileTypeFromBuffer } from 'file-type'
const buffer = await readFileBuffer(file)
const type = await fileTypeFromBuffer(buffer)
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!type || !ALLOWED_TYPES.includes(type.mime)) {
  return res.status(400).json({ error: 'Invalid file type' })
}

2. File Size Limits

// 🔴 No size limit = storage exhaustion / DoS
// 🟢 Enforce strict size limits
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024,  // 5MB max
    files: 5                     // max 5 files per request
  }
})

// Also limit at nginx/server level:
# nginx: client_max_body_size 10M;

3. Executable Extensions Blocked

// 🔴 Accepting executable files
// .php, .php3, .phtml, .php5
// .js (in wrong context), .jsp, .aspx, .exe
// .sh, .bat, .cmd, .ps1
// .py (in web-accessible directory)

const BLOCKED_EXTENSIONS = ['.php', '.php3', '.phtml', '.php5', '.js', 
  '.jsx', '.ts', '.tsx', '.asp', '.aspx', '.jsp', '.exe', '.sh', '.bat',
  '.cmd', '.ps1', '.py', '.rb', '.pl', '.cgi']

const ext = path.extname(file.name).toLowerCase()
if (BLOCKED_EXTENSIONS.includes(ext)) {
  return res.status(400).json({ error: 'File type not allowed' })
}

4. Store Outside Web Root

// 🔴 Files stored in public web directory
// uploads/ → accessible as /uploads/malicious.php

// 🟢 Store outside web root, serve via streaming
const uploadDir = '/var/app/uploads/'  // NOT in public/
// Serve via express:
app.get('/files/:id', auth, async (req, res) => {
  const file = await File.findOne({ _id: req.params.id, userId: req.user.id })
  if (!file) return res.status(404)
  res.setHeader('Content-Disposition', `attachment; filename="${file.originalName}"`)
  res.setHeader('Content-Type', file.mimeType)
  fs.createReadStream(file.path).pipe(res)
})

5. Randomize Stored Filename

// 🔴 Use original filename (path traversal, overwrite attacks)
const filePath = path.join('/uploads/', file.originalname)  // ← ../../../etc/passwd

// 🟢 Generate random filename, store mapping in DB
const storedName = crypto.randomBytes(16).toString('hex') + path.extname(file.originalname)
const filePath = path.join('/var/app/uploads/', storedName)
await db.files.create({ originalName: file.originalname, storedName, userId: req.user.id })

6. Path Traversal in Filename

// 🔴 Original filename used in path construction
const filePath = path.join(uploadDir, req.body.filename)
// Attack: filename = "../../../etc/passwd"

// 🟢 Sanitize filename
const safeName = path.basename(req.body.filename)  // strips directory components
// Also randomize (see check 5)

7. Symlink Attack Prevention

// 🔴 Symlink in uploaded archive points to system file
// /uploads/exploit.zip contains symlink: link -> /etc/passwd

// 🟢 Resolve real path after extraction
const realPath = fs.realpathSync(extractedFile)
if (!realPath.startsWith(path.resolve(uploadDir))) {
  throw new Error('Symlink attack detected')
}

Zip Slip (Archive Extraction)

8. Validate All Paths in Archive

// Already covered in instruction 11, repeated here for emphasis
const zip = new AdmZip(uploadedArchive)
for (const entry of zip.getEntries()) {
  const destPath = path.resolve(extractDir, entry.entryName)
  if (!destPath.startsWith(path.resolve(extractDir) + path.sep)) {
    return res.status(400).json({ error: 'Malicious archive rejected' })
  }
}

Image Processing Safety

9. Image Parser Security

// 🔴 Some image parsers have vulnerabilities (ImageMagick, Pillow)
// Attack: Polyglot files (JPEG that is also valid PHP)
// Attack: SVG with embedded script
// Attack: EXIF data with malicious content

// 🟢 Strip EXIF data from images
import sharp from 'sharp'
await sharp(inputBuffer)
  .rotate()   // corrects orientation AND strips EXIF
  .toFile(outputPath)

// 🟢 Re-encode images (converts and strips all metadata)
await sharp(inputBuffer).jpeg({ quality: 85 }).toFile(outputPath)

// 🔴 Never serve user-uploaded SVG directly
// SVG can contain: <script>, external references, CSS injection
// 🟢 Either reject SVG or sanitize with DOMPurify before serving

10. Virus/Malware Scanning (Guided)

For production apps handling untrusted files:

// Advise user to integrate ClamAV or similar
// clamscan --remove uploaded-file.exe
// Or use cloud service: VirusTotal API, AWS GuardDuty

Cloud Storage (S3, GCS, Azure Blob)

11. Pre-signed URLs

// 🟢 Use pre-signed URLs for direct upload (bypass your server)
const command = new PutObjectCommand({
  Bucket: process.env.S3_BUCKET,
  Key: `uploads/${userId}/${randomName}`,
  ContentType: 'image/jpeg',
  ContentLength: maxFileSize
})
const url = await getSignedUrl(s3, command, { expiresIn: 300 })  // 5 min
// Validate the upload on your server AFTER completion

12. Bucket Not Public

  • User-uploaded files must never be in a public-read bucket
  • Serve via pre-signed download URLs with expiry
  • Check bucket policy has no public access grants