Hardening upload file API tidak cukup dengan memeriksa ekstensi file atau header Content-Type. Penyerang bisa mengirim file dengan MIME palsu, nama file berbahaya, ukuran ekstrem, arsip yang meledak saat diekstrak, atau file yang sebenarnya berisi malware tetapi terlihat seperti dokumen biasa.

Kalau Anda membangun endpoint upload di backend modern, pendekatan yang aman adalah membuat alur berlapis: batasi ukuran, gunakan allowlist tipe file, deteksi MIME berdasarkan isi file, simpan dengan nama acak di lokasi non-publik, masukkan file ke quarantine, lakukan scan antivirus secara asynchronous, lalu baru publish jika hasil scan bersih. Tambahkan juga autentikasi, rate limit, logging, dan audit agar penyalahgunaan bisa dilacak.

Ancaman umum pada endpoint upload file

Masalah utama upload file bukan hanya soal kapasitas penyimpanan. Endpoint ini memproses input biner dari pengguna, dan itu membuka banyak jalur serangan.

1. MIME atau Content-Type palsu

Header Content-Type dikirim oleh klien dan tidak bisa dipercaya sepenuhnya. File executable, skrip, atau malware bisa dikirim dengan header image/jpeg atau application/pdf.

Karena itu, validasi harus membaca signature atau pola biner file, bukan hanya metadata dari request.

2. Ekstensi ganda dan nama file menipu

Contoh klasik adalah nama seperti invoice.pdf.exe, photo.jpg.php, atau variasi dengan karakter Unicode yang membingungkan. Jika sistem hanya memeriksa bagian awal nama file atau hanya melakukan pencocokan sederhana, file berbahaya bisa lolos.

3. Path traversal

Nama file seperti ../../app.env atau variasi encoded dapat mencoba menulis file ke direktori yang tidak semestinya. Risiko ini muncul bila backend menggunakan nama file dari pengguna secara langsung untuk membentuk path penyimpanan.

4. File terlalu besar atau upload berulang

Tanpa batas ukuran dan rate limit, endpoint upload bisa dipakai untuk resource exhaustion: menghabiskan bandwidth, disk, CPU, dan worker scan antivirus.

5. Malware dan file berbahaya terselubung

Dokumen, PDF, skrip, atau arsip bisa mengandung payload berbahaya. Bahkan bila file tidak dieksekusi di server, file tersebut tetap bisa berbahaya bagi pengguna lain yang mengunduhnya.

6. Zip bomb dan arsip bersarang

File ZIP kecil bisa mengembang menjadi ukuran sangat besar setelah diekstrak. Arsip juga bisa berisi ribuan file kecil, nested archive, atau nama path berbahaya. Jika aplikasi mengekstrak arsip tanpa kontrol, CPU, RAM, dan disk bisa habis.

7. Metadata berbahaya

Gambar dan dokumen sering membawa metadata seperti EXIF, XMP, atau field teks lain. Metadata bisa memicu masalah privasi, injeksi saat ditampilkan tanpa sanitasi, atau menyebabkan parser tertentu bekerja tidak aman.

Prinsip desain alur upload yang aman

Tujuan hardening bukan membuat upload file mustahil diserang, tetapi membatasi dampak dan menutup jalur serangan paling umum. Pendekatan yang paling masuk akal adalah defense in depth.

Alur yang direkomendasikan

  1. Klien terautentikasi meminta upload.
  2. Server memeriksa otorisasi, kuota, dan rate limit.
  3. Server memvalidasi ukuran maksimum request dan ukuran file.
  4. Server memeriksa ekstensi terhadap allowlist.
  5. Server membaca sebagian isi file untuk mendeteksi MIME sebenarnya.
  6. Server menghasilkan nama file acak dan menyimpan file ke lokasi non-publik atau bucket privat.
  7. Status file ditandai sebagai quarantine.
  8. Job asynchronous menjalankan antivirus scan dan validasi tambahan.
  9. Jika aman, file dipindahkan atau statusnya diubah menjadi published.
  10. Jika gagal scan, file tetap di quarantine atau dihapus sesuai kebijakan.

Prinsip penting: file yang baru di-upload jangan langsung dianggap aman atau langsung bisa diakses publik.

Validasi awal: ukuran, ekstensi, dan tipe yang diizinkan

Batasi ukuran seawal mungkin

Batas ukuran harus diterapkan di beberapa lapisan:

  • Reverse proxy atau API gateway.
  • Server aplikasi.
  • Logika bisnis per endpoint atau per tipe file.

Dengan begitu, request yang jelas terlalu besar bisa ditolak sebelum memakan resource aplikasi lebih jauh. Selain ukuran per file, pertimbangkan juga:

  • jumlah file per request,
  • kuota per pengguna atau tenant,
  • batas total upload per periode waktu.

Gunakan allowlist, bukan blocklist

Untuk kebanyakan API, lebih aman mendefinisikan tipe yang benar-benar didukung daripada mencoba memblokir daftar file berbahaya yang tidak ada habisnya.

Contoh allowlist yang masuk akal:

  • gambar: JPEG, PNG, WebP,
  • dokumen: PDF,
  • arsip: ZIP, hanya jika memang dibutuhkan.

Kalau bisnis tidak butuh DOCM, SVG, atau file script, jangan izinkan.

Jangan percaya ekstensi file sebagai sumber kebenaran

Ekstensi tetap berguna untuk validasi awal dan UX, tetapi harus dianggap sebagai sinyal lemah. Pencocokan akhir harus mempertimbangkan isi file.

Deteksi MIME berbasis isi file

Validasi MIME yang benar memeriksa magic number, header biner, atau pola struktur file. Ini lebih kuat daripada hanya melihat nama file dan header request.

Mengapa cara ini bekerja

Banyak format file memiliki penanda yang khas di awal atau dalam struktur filenya. Misalnya, PNG, PDF, JPEG, dan ZIP punya pola yang relatif mudah dikenali. Jika file diklaim sebagai PDF tetapi signature-nya tidak cocok, maka file tersebut patut ditolak atau dikarantina untuk pemeriksaan lebih lanjut.

Contoh pseudo-code validasi upload

function handleUpload(request, user) {
  enforceAuth(user)
  enforceRateLimit(user.id, request.ip)
  enforceMaxRequestSize(request)

  file = request.file("document")
  if (!file) throw BadRequest("file wajib ada")

  if (file.size <= 0 || file.size > MAX_FILE_SIZE) {
    throw ValidationError("ukuran file tidak valid")
  }

  originalName = file.originalName
  ext = normalizeExtension(originalName)
  if (!ALLOWED_EXTENSIONS.contains(ext)) {
    throw ValidationError("ekstensi file tidak diizinkan")
  }

  detectedMime = detectMimeFromContent(file.stream)
  if (!ALLOWED_MIME_TYPES.contains(detectedMime)) {
    throw ValidationError("tipe file tidak diizinkan")
  }

  if (!extensionMatchesMime(ext, detectedMime)) {
    throw ValidationError("ekstensi dan isi file tidak cocok")
  }

  safeId = generateRandomId()
  storageKey = "quarantine/" + safeId

  saveToPrivateStorage(file.stream, storageKey)

  createFileRecord({
    id: safeId,
    ownerId: user.id,
    originalName: originalName,
    detectedMime: detectedMime,
    size: file.size,
    status: "quarantine"
  })

  enqueueScanJob(safeId)

  return {
    file_id: safeId,
    status: "quarantine"
  }
}

Poin penting dari contoh di atas:

  • Nama asli file tidak dipakai sebagai nama penyimpanan.
  • Status awal adalah quarantine, bukan langsung aktif.
  • Deteksi MIME berbasis isi dilakukan sebelum file tersedia untuk konsumsi lebih lanjut.

Penyimpanan aman: randomisasi nama, lokasi privat, dan object storage

Jangan gunakan nama file dari pengguna sebagai path penyimpanan

Gunakan ID acak atau UUID dan pisahkan metadata asli di database. Ini mencegah:

  • path traversal,
  • tabrakan nama file,
  • kebocoran informasi dari nama file asli,
  • masalah karakter khusus atau encoding aneh.

Simpan di luar web root

Kalau file disimpan di filesystem lokal, letakkan di direktori yang tidak dilayani langsung oleh web server. Akses file sebaiknya lewat aplikasi atau lewat URL bertanda tangan untuk file yang sudah lolos verifikasi.

Gunakan bucket privat bila memakai object storage

Untuk arsitektur modern, object storage privat lebih aman daripada folder publik. File yang masih di quarantine tidak boleh memiliki URL publik permanen. Jika perlu akses sementara, gunakan signed URL dengan masa berlaku pendek dan hanya setelah status file aman.

Kapan signed URL relevan

Signed URL berguna untuk:

  • upload langsung dari klien ke object storage tanpa melewati server aplikasi,
  • download sementara file yang sudah lolos scan,
  • mengurangi beban bandwidth pada backend.

Namun, meski upload dilakukan langsung ke storage, tetap butuh alur verifikasi: objek yang baru masuk harus ditandai quarantine, lalu diproses oleh worker scan sebelum boleh dipakai.

Quarantine dan antivirus scan asynchronous

Mengapa scan sebaiknya asynchronous

Scan antivirus bisa memakan waktu, terutama untuk file besar atau sistem dengan antrean tinggi. Jika semua scan dilakukan sinkron di request utama, latensi API naik dan pengguna akan menunggu terlalu lama. Karena itu, pola yang umum adalah:

  1. Terima upload.
  2. Simpan ke area quarantine.
  3. Masukkan job ke queue.
  4. Worker menjalankan scan.
  5. Perbarui status file.

Status file yang jelas

Jangan simpan status sebagai boolean tunggal seperti is_safe. Lebih baik gunakan status eksplisit, misalnya:

  • quarantine
  • scanning
  • clean
  • rejected
  • scan_failed

Status yang rinci memudahkan retry, observability, dan audit.

Contoh worker scan

function scanWorker(fileId) {
  file = getFileRecord(fileId)
  if (!file || file.status !== "quarantine") return

  updateStatus(fileId, "scanning")

  try {
    stream = openPrivateStorage(file.storageKey)

    scanResult = antivirusScan(stream)
    validationResult = runAdditionalChecks(stream, file.detectedMime)

    if (scanResult.infected || !validationResult.ok) {
      updateStatus(fileId, "rejected")
      recordSecurityEvent(fileId, "upload_rejected", {
        infected: scanResult.infected,
        reason: validationResult.reason
      })
      return
    }

    publishFile(fileId)
    updateStatus(fileId, "clean")
    recordSecurityEvent(fileId, "upload_clean", {})
  } catch (err) {
    updateStatus(fileId, "scan_failed")
    recordSecurityEvent(fileId, "scan_failed", {
      error: safeErrorMessage(err)
    })
  }
}

Verifikasi hasil scan

Kesalahan umum adalah menganggap job scan pasti berhasil. Padahal worker bisa timeout, service scan bisa down, atau file tidak terbaca. Kebijakan yang aman adalah fail closed: jika scan gagal atau status tidak jelas, file jangan dipublikasikan.

Selain itu, simpan metadata scan seperlunya, misalnya:

  • waktu scan terakhir,
  • hasil scan,
  • versi definisi atau sumber scanner jika relevan,
  • alasan reject.

Ini membantu investigasi dan retensi audit.

Penanganan ancaman spesifik

Ekstensi ganda

Normalisasi nama file dan ambil ekstensi paling akhir secara hati-hati, tetapi jangan berhenti di sana. Jika file bernama report.pdf.exe, ekstensi akhirnya adalah .exe. Jika file bernama image.jpg tetapi isi file adalah executable atau skrip, deteksi MIME harus menolaknya.

Path traversal

Solusi terbaik adalah tidak pernah memakai nama file dari pengguna sebagai bagian path final. Jika Anda tetap menyimpan nama asli untuk ditampilkan, perlakukan itu hanya sebagai metadata, bukan penentu lokasi file.

Zip bomb dan arsip

Jika sistem memang harus menerima arsip:

  • batasi ukuran file arsip,
  • batasi rasio kompresi terhadap ukuran hasil ekstraksi,
  • batasi jumlah file di dalam arsip,
  • batasi kedalaman nested archive,
  • validasi path setiap entri di dalam arsip agar tidak keluar direktori target,
  • jangan mengekstrak arsip di request utama.

Jika bisnis tidak butuh arsip, keputusan paling aman adalah menolak ZIP sama sekali.

Metadata berbahaya

Untuk gambar dan dokumen, pertimbangkan sanitasi metadata atau proses re-encode. Contohnya, gambar bisa diproses ulang ke format yang sama untuk membuang sebagian metadata yang tidak dibutuhkan. Trade-off-nya adalah CPU tambahan dan potensi perubahan kualitas atau karakteristik file.

Upload berulang dan abuse

Penyerang bisa mengirim file yang sama berulang kali untuk membebani storage dan scanner. Mitigasi yang umum:

  • rate limit per user, per token, dan per IP,
  • kuota harian atau bulanan,
  • batas jumlah file pending scan,
  • deteksi duplikasi berbasis hash bila sesuai kebutuhan.

Hash file juga berguna untuk deduplikasi internal, tetapi jangan gunakan hash sebagai satu-satunya identitas akses.

Auth, otorisasi, dan model akses file

Endpoint upload seharusnya tidak terbuka tanpa kontrol. Minimal, pertimbangkan hal berikut:

  • Authentication: hanya user atau service yang sah boleh upload.
  • Authorization: user hanya boleh upload ke resource yang memang ia miliki atau berhak akses.
  • Quota enforcement: batasi berdasarkan paket, tenant, atau kebijakan organisasi.

Untuk akses file setelah upload, jangan langsung mengekspose path internal. Gunakan endpoint download terproteksi atau signed URL yang dibuat hanya untuk file berstatus clean.

Logging, audit, dan observability

Hardening tidak lengkap tanpa jejak yang baik. Saat ada file berbahaya lolos atau ada gelombang serangan upload, Anda butuh data untuk memahami apa yang terjadi.

Apa yang perlu dicatat

  • user ID atau client ID,
  • alamat IP dan user agent jika relevan,
  • nama file asli, ukuran, hash, dan MIME terdeteksi,
  • status transisi: quarantine, scanning, clean, rejected,
  • hasil scan dan alasan reject,
  • waktu proses dan kegagalan worker.

Hindari mencatat data sensitif secara berlebihan. Jika file mengandung informasi pribadi, log harus dirancang agar tidak menjadi sumber kebocoran baru.

Indikator operasional yang berguna

  • jumlah upload per menit,
  • persentase file ditolak,
  • durasi rata-rata scan,
  • panjang antrean scan,
  • jumlah file berstatus scan_failed,
  • rasio upload dari IP atau akun yang sama.

Metrik ini membantu membedakan masalah operasional biasa dari pola serangan.

Contoh arsitektur implementasi

Berikut alur yang umum dipakai di backend modern:

  1. Klien memanggil endpoint inisialisasi upload.
  2. Server memverifikasi auth, kuota, dan kebijakan file.
  3. File dikirim ke server aplikasi atau langsung ke object storage privat.
  4. Objek disimpan di prefix atau folder quarantine/.
  5. Database menyimpan record file dengan status quarantine.
  6. Queue menerima job scan.
  7. Worker memeriksa MIME aktual, scan antivirus, dan validasi lanjutan.
  8. Jika bersih, file dipindah ke prefix published/ atau status diubah menjadi clean.
  9. File baru dapat diakses lewat endpoint aman atau signed URL.

Pola ini memisahkan jalur upload dari jalur publish. Hasilnya, file mentah tidak langsung menjadi bagian dari sistem yang dikonsumsi pengguna lain.

Kesalahan umum yang sering terjadi

  • Hanya memeriksa ekstensi file.
  • Mempercayai header Content-Type dari klien.
  • Menyimpan file langsung di folder publik.
  • Menggunakan nama file asli sebagai nama penyimpanan.
  • Langsung mempublikasikan file sebelum scan selesai.
  • Tidak menangani status scan_failed.
  • Tidak membatasi ukuran, jumlah file, atau laju upload.
  • Mengekstrak arsip tanpa pembatasan.
  • Tidak mencatat event keamanan dan alasan penolakan.

Trade-off UX vs keamanan

Semakin ketat validasi, semakin besar kemungkinan pengguna menemui penolakan yang terasa mengganggu. Misalnya, scan asynchronous berarti file tidak langsung tersedia. Sanitasi metadata atau re-encode gambar juga bisa menambah waktu proses.

Trade-off yang umum:

  • Keamanan tinggi: file baru harus menunggu scan selesai sebelum bisa diakses. Lebih aman, tetapi UX lebih lambat.
  • UX cepat: tampilkan status “sedang diproses” dan beri notifikasi saat file siap. Ini biasanya kompromi yang baik.
  • Dukungan format luas: lebih fleksibel, tetapi permukaan serangan bertambah.
  • Allowlist sempit: lebih aman dan lebih mudah diuji, tetapi mungkin membatasi use case.

Pilih kebijakan berdasarkan konteks. Sistem internal untuk avatar profil jelas berbeda dengan platform yang menerima dokumen dari pihak eksternal dalam jumlah besar.

Checklist hardening upload file API

  • Gunakan autentikasi dan otorisasi untuk endpoint upload.
  • Terapkan batas ukuran di proxy, aplikasi, dan logika bisnis.
  • Batasi jumlah file per request dan kuota per pengguna.
  • Gunakan allowlist ekstensi dan MIME.
  • Deteksi MIME berdasarkan isi file, bukan hanya header request.
  • Validasikan kecocokan antara ekstensi dan MIME terdeteksi.
  • Gunakan nama file acak untuk penyimpanan.
  • Simpan file di luar web root atau di object storage privat.
  • Tandai semua file baru sebagai quarantine.
  • Jalankan antivirus scan secara asynchronous melalui queue.
  • Gunakan kebijakan fail closed jika scan gagal.
  • Batasi atau tolak arsip jika tidak diperlukan.
  • Jika menerima arsip, lindungi dari zip bomb dan path traversal saat ekstraksi.
  • Pertimbangkan sanitasi metadata atau re-encode untuk tipe file tertentu.
  • Terapkan rate limit per user dan per IP.
  • Gunakan signed URL hanya untuk file yang sudah lolos verifikasi.
  • Catat audit log untuk upload, scan, reject, dan download.
  • Pantau metrik antrean scan, reject rate, dan scan failure.

Penutup

Hardening upload file API adalah kombinasi kontrol validasi, isolasi penyimpanan, dan proses verifikasi yang disiplin. Validasi MIME berbasis isi file menutup celah spoofing yang tidak bisa ditangani oleh ekstensi saja. Quarantine mencegah file mentah langsung dipakai. Scan asynchronous menjaga performa API tetap masuk akal sambil memberi ruang untuk pemeriksaan keamanan yang lebih dalam.

Jika Anda hanya mengambil satu prinsip dari artikel ini, ambil yang ini: file upload harus dianggap tidak tepercaya sampai terbukti aman. Dari prinsip itu, keputusan teknis seperti randomisasi nama, storage privat, queue scan, status quarantine, dan audit log akan terasa jauh lebih konsisten.