Hardening upload file di Nuxt.js tidak cukup dengan validasi di browser. Untuk aplikasi produksi, kontrol utama harus berada di endpoint server Nitro: validasi ukuran dan tipe file, pembatasan jumlah file, penamaan aman, penyimpanan di lokasi non-publik, serta proteksi abuse seperti autentikasi, otorisasi, dan rate limit.

Masalah utamanya sederhana: file upload adalah permukaan serangan yang luas. Penyerang bisa mengirim file terlalu besar, MIME palsu, nama file berbahaya, payload yang memicu parser, atau melakukan spam upload untuk menghabiskan storage dan bandwidth. Solusinya adalah membangun alur upload yang ketat dan eksplisit, lalu memastikan error response dan logging tetap aman.

Model ancaman pada fitur upload file

Sebelum menulis kode, tentukan ancaman yang ingin dibatasi. Ini membantu Anda memilih validasi yang tepat dan menghindari rasa aman palsu.

  • File terlalu besar yang menghabiskan memori, disk, atau waktu proses.
  • MIME spoofing, misalnya file executable yang diklaim sebagai image.
  • Ekstensi berbahaya seperti .php, .html, .svg tertentu, atau file dengan double extension seperti invoice.pdf.exe.
  • Path traversal melalui nama file, misalnya ../../app.config.
  • Upload massal untuk abuse bandwidth dan storage.
  • Unauthorized upload dari user anonim atau user yang tidak punya izin.
  • Kebocoran detail internal lewat pesan error yang terlalu spesifik.

Kesalahan paling umum adalah hanya mengandalkan validasi client. Validasi di browser berguna untuk UX, tetapi tidak bisa dipercaya untuk keamanan.

Prinsip hardening upload file di Nuxt.js

1. Validasi harus dilakukan di server

Pada Nuxt.js, jalur yang paling tepat adalah endpoint di server routes atau Nitro server API. Semua file yang diterima harus diperiksa ulang di server, meskipun form di client sudah membatasi accept, ukuran, atau jumlah file.

2. Gunakan allowlist, bukan blocklist

Lebih aman mendefinisikan tipe file yang diizinkan daripada mencoba memblokir semua yang berbahaya. Misalnya, bila aplikasi hanya menerima dokumen PDF dan gambar JPEG/PNG, tolak semua tipe lain secara default.

3. Jangan percaya nama file asli

Nama file dari client adalah input tak tepercaya. File harus diberi nama baru yang aman, misalnya berbasis UUID atau random identifier. Nama asli boleh disimpan sebagai metadata setelah disanitasi, tetapi jangan dipakai langsung sebagai path penyimpanan.

4. Simpan file di luar web root atau object storage

Jangan menyimpan file upload di direktori yang bisa diakses langsung oleh web server tanpa kontrol. Lebih aman menyimpan file:

  • di direktori non-publik pada server, lalu sajikan melalui endpoint terautentikasi, atau
  • di object storage seperti S3-compatible storage, dengan akses melalui signed URL atau proxy API.

Tujuannya adalah mencegah file mentah langsung dieksekusi atau diakses tanpa otorisasi.

Alur upload yang disarankan

  1. Client mengirim request multipart/form-data ke endpoint Nitro.
  2. Server memverifikasi autentikasi user.
  3. Server memeriksa otorisasi: role, scope, atau policy upload.
  4. Server menerapkan rate limit per IP dan/atau per user.
  5. Server membatasi jumlah file per request.
  6. Untuk tiap file: validasi ukuran, ekstensi, dan MIME; sanitasi metadata; buat nama file aman.
  7. Server menyimpan file ke lokasi non-publik atau object storage.
  8. Server menulis audit log minimal: siapa mengunggah, kapan, IP, hasil validasi, dan identifier file.
  9. Server mengembalikan respons generik yang cukup untuk client, tanpa membocorkan path internal atau stack trace.

Contoh endpoint Nitro untuk upload aman

Struktur dan helper bisa berbeda antar proyek, tetapi pola berikut menunjukkan kontrol inti yang perlu ada. Contoh ini sengaja tetap generik agar tidak bergantung pada detail versi tertentu.

// server/api/upload.post.ts
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { join, extname } from 'node:path'

const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.pdf'])
const ALLOWED_MIME = new Set([
  'image/jpeg',
  'image/png',
  'application/pdf'
])
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const MAX_FILES = 3

function sanitizeOriginalName(name = '') {
  return name
    .normalize('NFKC')
    .replace(/[\u0000-\u001F\u007F]/g, '')
    .replace(/[/\\?%*:|"<>]/g, '-')
    .trim()
    .slice(0, 120)
}

function assertAuthorized(event: any) {
  const user = event.context.user
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }
  if (!['admin', 'editor'].includes(user.role)) {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
  }
  return user
}

function assertFileAllowed(file: any) {
  const originalName = sanitizeOriginalName(file.filename || '')
  const extension = extname(originalName).toLowerCase()
  const mime = String(file.type || '').toLowerCase()
  const size = file.data?.length || 0

  if (!ALLOWED_EXTENSIONS.has(extension)) {
    throw createError({ statusCode: 400, statusMessage: 'Tipe file tidak diizinkan' })
  }

  if (!ALLOWED_MIME.has(mime)) {
    throw createError({ statusCode: 400, statusMessage: 'MIME file tidak diizinkan' })
  }

  if (size <= 0 || size > MAX_FILE_SIZE) {
    throw createError({ statusCode: 413, statusMessage: 'Ukuran file melebihi batas' })
  }

  return { originalName, extension, mime, size }
}

export default defineEventHandler(async (event) => {
  const user = assertAuthorized(event)

  // Terapkan rate limit di middleware/utility terpisah
  await enforceRateLimit(event, {
    key: `${getRequestIP(event) || 'unknown'}:${user.id}`,
    limit: 10,
    windowSeconds: 60
  })

  const parts = await readMultipartFormData(event)
  if (!parts || parts.length === 0) {
    throw createError({ statusCode: 400, statusMessage: 'Tidak ada file yang dikirim' })
  }

  const files = parts.filter(part => part.filename)
  if (files.length === 0) {
    throw createError({ statusCode: 400, statusMessage: 'Payload multipart tidak valid' })
  }

  if (files.length > MAX_FILES) {
    throw createError({ statusCode: 400, statusMessage: 'Jumlah file melebihi batas' })
  }

  const uploadDir = '/var/app/uploads-private'
  await mkdir(uploadDir, { recursive: true })

  const saved = []

  for (const file of files) {
    const meta = assertFileAllowed(file)
    const safeName = `${randomUUID()}${meta.extension}`
    const targetPath = join(uploadDir, safeName)

    // Simpan di lokasi non-publik
    await writeFile(targetPath, file.data)

    saved.push({
      id: safeName,
      originalName: meta.originalName,
      mime: meta.mime,
      size: meta.size
    })
  }

  await writeAuditLog(event, {
    action: 'file.upload',
    userId: user.id,
    fileCount: saved.length,
    files: saved.map(f => ({ id: f.id, mime: f.mime, size: f.size }))
  })

  return {
    success: true,
    files: saved.map(f => ({
      id: f.id,
      name: f.originalName,
      mime: f.mime,
      size: f.size
    }))
  }
})

Beberapa poin penting dari contoh di atas:

  • Autentikasi dan otorisasi dilakukan sebelum file diproses lebih jauh.
  • Rate limit dipasang sebelum parsing berat atau penyimpanan file.
  • Jumlah file dibatasi per request.
  • Ekstensi dan MIME diverifikasi lewat allowlist.
  • Nama file diganti dengan UUID, bukan memakai nama asli.
  • Lokasi penyimpanan berada di direktori private, bukan di bawah public/.

Validasi yang benar: MIME, ekstensi, ukuran, dan jumlah file

Validasi MIME di server

MIME type dari multipart request berguna, tetapi tetap merupakan input dari client. Karena itu, validasi MIME sebaiknya dipadukan dengan ekstensi dan, bila kebutuhan keamanan tinggi, verifikasi signature atau magic number file.

Contohnya, file PNG biasanya memiliki header tertentu. Memeriksa signature biner membantu mendeteksi file yang hanya mengganti ekstensi atau header metadata permukaan. Ini terutama penting jika file akan diproses lebih lanjut oleh library image, PDF parser, atau antivirus scanner.

Whitelist ekstensi

Ekstensi tetap relevan karena memengaruhi perilaku pengguna, pipeline penyimpanan, dan kadang integrasi downstream. Gunakan allowlist sempit seperti .jpg, .png, dan .pdf. Hindari menerima format yang lebih sulit diamankan jika tidak benar-benar diperlukan.

Berhati-hatilah dengan:

  • Double extension: file.pdf.exe
  • Case variation: .JPG
  • Karakter unicode yang menyamarkan nama file

Batas ukuran file

Batas ukuran harus diterapkan di server, idealnya sedini mungkin. Jika file terlalu besar, tolak dengan respons yang sesuai tanpa mencoba memproses seluruh payload lebih lama dari yang diperlukan. Batas yang wajar bergantung pada use case; prinsipnya adalah memilih batas minimum yang masih memenuhi kebutuhan bisnis.

Batas jumlah file

Jangan hanya membatasi ukuran per file. Batasi juga:

  • jumlah file per request,
  • jumlah total byte per request,
  • jumlah upload per user per periode waktu.

Tanpa batas ini, penyerang masih bisa mengirim banyak file kecil untuk menghabiskan resource.

Penamaan file aman dan sanitasi nama

Nama file asli tidak boleh digunakan sebagai nama file penyimpanan. Alasannya:

  • mengandung karakter path separator,
  • berisiko tabrakan nama,
  • bisa dipakai untuk log injection atau tampilan UI yang menyesatkan,
  • sering tidak konsisten lintas sistem operasi.

Praktik yang lebih aman adalah:

  1. Simpan nama file fisik sebagai identifier acak, misalnya UUID.
  2. Simpan nama asli hanya sebagai metadata setelah disanitasi.
  3. Batasi panjang nama asli yang disimpan.
  4. Hilangkan karakter kontrol, slash, backslash, dan karakter yang berpotensi merusak path atau log.

Jika file akan diunduh kembali, gunakan header seperti Content-Disposition yang dibentuk aman dari metadata, bukan path file mentah.

Penyimpanan: direktori private vs object storage

Direktori private di server

Cocok jika volume upload relatif kecil, deployment sederhana, dan Anda mengontrol host secara penuh. Kelebihannya adalah implementasi cepat dan latensi lokal rendah. Kekurangannya: scaling lebih sulit, backup harus diatur sendiri, dan sinkronisasi antarnode menjadi masalah jika aplikasi berjalan di banyak instance.

Object storage

Lebih cocok untuk aplikasi produksi yang butuh durability lebih baik, lifecycle policy, atau arsitektur horizontal. Object storage juga memudahkan pemisahan antara aplikasi dan penyimpanan file. Namun, Anda tetap perlu menjaga policy akses, signed URL, dan validasi file sebelum upload permanen diterima oleh sistem.

Untuk file sensitif, hindari bucket publik default. Sajikan akses lewat signed URL berdurasi pendek atau endpoint backend yang memeriksa izin user.

Autentikasi, otorisasi per peran, dan abuse guard

Autentikasi upload

Jangan membuka endpoint upload untuk publik kecuali memang diperlukan. Bahkan untuk use case publik, pertimbangkan token sekali pakai, CAPTCHA, atau alur pre-signed yang dibatasi ketat.

Otorisasi per peran

Tidak semua user yang login harus bisa mengunggah semua jenis file. Buat policy sederhana seperti:

  • admin: boleh upload dokumen dan gambar
  • editor: hanya gambar dan PDF terbatas
  • viewer: tidak boleh upload

Otorisasi ini sebaiknya diverifikasi di server berdasarkan context user, bukan berdasarkan role yang dikirim dari client.

Rate limit per IP dan per user

Rate limit mencegah abuse dasar dan menurunkan beban saat terjadi spam upload. Praktik umum:

  • gunakan key berbasis IP untuk user anonim atau fallback,
  • gunakan key berbasis user ID untuk user login,
  • gabungkan keduanya untuk endpoint sensitif.

Implementasinya biasanya memakai store bersama seperti Redis agar konsisten di banyak instance aplikasi. Jika belum punya infrastruktur itu, rate limit in-memory masih berguna untuk lingkungan sederhana, tetapi tidak ideal pada deployment multi-instance.

async function enforceRateLimit(event, { key, limit, windowSeconds }) {
  // Pseudocode: implementasikan dengan Redis atau storage lain.
  // Jika melebihi limit, lempar 429 tanpa detail internal.
  const allowed = await rateLimitStore.consume(key, limit, windowSeconds)
  if (!allowed) {
    throw createError({ statusCode: 429, statusMessage: 'Terlalu banyak permintaan' })
  }
}

Proteksi abuse tambahan

  • Timeout dan body limit di reverse proxy atau gateway.
  • Quota storage per user/tenant agar satu akun tidak menghabiskan kapasitas.
  • Asynchronous scanning untuk file berisiko tinggi, jika domain Anda menuntutnya.
  • Deduplication atau hash bila upload file identik sering terjadi dan storage mahal.

Menolak payload berbahaya

"Payload berbahaya" tidak selalu berarti malware yang dapat dieksekusi langsung. Dalam konteks upload API, ini juga mencakup input yang sengaja dirancang untuk merusak parser, memicu error path, atau menyiasati aturan validasi.

Langkah praktis yang bisa diterapkan:

  • Tolak request non-multipart jika endpoint memang hanya menerima multipart.
  • Tolak part tanpa filename bila skema API mewajibkan file.
  • Tolak file kosong jika bisnis tidak memerlukannya.
  • Tolak karakter kontrol atau nama file ekstrem panjang.
  • Tolak tipe file yang sebenarnya tidak perlu didukung.
  • Jangan pernah mengeksekusi, me-render, atau memproses file upload mentah tanpa isolasi yang cukup.

Jika Anda menerima format kompleks seperti Office document, archive, atau SVG, risiko meningkat karena parser dan rendering surface lebih luas. Bila tidak ada kebutuhan bisnis yang jelas, lebih aman tidak mengizinkannya.

Logging audit dan observability

Upload file adalah aktivitas yang layak diaudit, terutama jika file berkaitan dengan data pengguna, dokumen bisnis, atau area yang diawasi kepatuhan. Audit log sebaiknya mencatat:

  • waktu upload,
  • user ID atau actor ID,
  • IP atau identitas jaringan yang relevan,
  • jumlah file, ukuran, MIME, dan hasil validasi,
  • identifier file yang disimpan, bukan path internal lengkap.

Yang perlu dihindari:

  • menulis binary file ke log,
  • menyimpan token autentikasi ke log,
  • menuliskan stack trace ke respons client.

Audit log membantu saat investigasi abuse, penyelesaian sengketa, atau analisis anomali, tetapi tetap harus memperhatikan kebijakan retensi dan privasi.

Penanganan error yang aman

Error response harus cukup jelas untuk client, tetapi tidak membocorkan detail implementasi. Contoh yang aman:

  • 400: tipe file tidak diizinkan
  • 401: belum login
  • 403: tidak punya izin upload
  • 413: ukuran file melebihi batas
  • 429: terlalu banyak permintaan

Hindari mengirimkan:

  • path file di server,
  • nama bucket internal,
  • query database,
  • stack trace atau nama class exception.

Simpan detail teknis lengkap di log server atau sistem observability, bukan di body respons API.

Validasi di client tetap berguna, tetapi hanya untuk UX

Form upload di client tetap sebaiknya memeriksa ukuran, jumlah file, dan tipe dasar agar pengguna mendapat feedback lebih cepat. Namun, perlakukan itu sebagai optimisasi UX, bukan lapisan keamanan.

<input
  type="file"
  multiple
  accept=".jpg,.jpeg,.png,.pdf"
/>

Kontrol accept membantu pengguna memilih file yang benar, tetapi browser tidak menjamin keamanan file yang dikirim. Semua aturan inti tetap harus divalidasi ulang di server.

Kesalahan umum saat membangun upload di Nuxt.js

  • Hanya mengandalkan validasi client.
  • Menyimpan file di folder publik lalu menganggapnya aman.
  • Menggunakan nama file asli sebagai path penyimpanan.
  • Memeriksa ekstensi saja tanpa MIME, atau MIME saja tanpa konteks lain.
  • Tidak membatasi jumlah file dan total ukuran request.
  • Tidak menerapkan rate limit pada endpoint upload.
  • Mengembalikan pesan error terlalu detail.
  • Tidak mencatat audit log untuk aksi upload penting.
  • Mengizinkan format yang tidak perlu seperti archive atau SVG tanpa alasan kuat.

Checklist hardening upload file di Nuxt.js

  • Endpoint upload berjalan di Nitro/server routes, bukan logika client.
  • Autentikasi diverifikasi sebelum proses upload lanjut.
  • Otorisasi berbasis role/policy diterapkan di server.
  • Rate limit aktif per IP dan/atau per user.
  • Jumlah file per request dibatasi.
  • Ukuran file per file dan total payload dibatasi.
  • MIME dan ekstensi divalidasi dengan allowlist.
  • Nama file asli disanitasi dan tidak dipakai sebagai nama file fisik.
  • Nama file penyimpanan menggunakan identifier acak.
  • File disimpan di luar web root atau di object storage private.
  • Respons error tidak membocorkan detail internal.
  • Audit log mencatat actor, waktu, IP, hasil, dan identifier file.
  • Format file yang diizinkan dibuat sesempit mungkin.

Penutup

Hardening upload file di Nuxt.js pada dasarnya adalah soal disiplin di sisi server: jangan percaya input dari client, terapkan validasi berlapis, batasi resource, dan kontrol siapa yang boleh mengunggah apa. Dengan endpoint Nitro yang memverifikasi MIME, ekstensi, ukuran, jumlah file, identitas user, dan laju request, Anda menutup sebagian besar celah paling umum pada fitur upload.

Mulailah dari aturan minimum yang ketat, lalu longgarkan hanya jika ada kebutuhan bisnis yang jelas. Pendekatan ini jauh lebih aman daripada memulai dengan endpoint upload yang permisif lalu mencoba menambal celah satu per satu setelah masuk ke produksi.