Upload file aman di Next.js tidak cukup hanya memeriksa ekstensi file di browser. Untuk aplikasi produksi, Anda perlu menganggap semua input upload sebagai data yang tidak tepercaya: MIME bisa dipalsukan, ekstensi bisa menipu, ukuran file bisa disalahgunakan, arsip bisa berisi ZIP bomb, dan endpoint upload sering menjadi target spam atau brute-force.

Pendekatan yang aman adalah membangun pipeline bertahap: autentikasi dan rate limit di depan, validasi server-side pada metadata dan konten dasar, penyimpanan terisolasi dengan nama file acak, lalu scan asinkron sebelum file dianggap siap dipakai. Dengan pola ini, kegagalan pada satu lapisan tidak langsung membuka jalan ke kompromi sistem.

Threat model upload file yang perlu dipahami

Sebelum menulis kode, tentukan ancaman yang benar-benar ingin dicegah. Untuk endpoint upload, beberapa risiko yang paling umum adalah:

  • Spoofed MIME type: klien mengirim Content-Type: image/png, tetapi isi file sebenarnya bukan PNG.
  • Ekstensi palsu: file bernama foto.jpg bisa saja berisi skrip, executable, atau arsip.
  • File terlalu besar: bisa menghabiskan memori, storage, bandwidth, atau waktu proses server.
  • Path traversal: nama file seperti ../../app.env tidak boleh pernah dipakai langsung sebagai path penyimpanan.
  • ZIP bomb / decompression bomb: arsip kecil yang saat diekstrak membesar ekstrem dan menghabiskan resource.
  • Malware: file lampiran atau dokumen dapat membawa payload berbahaya.
  • Penyalahgunaan endpoint: spam upload, brute-force, flood request, atau upload file ilegal yang membuat biaya storage membengkak.

Dari daftar ini, ada satu prinsip penting: jangan percaya data dari klien, termasuk nama file, MIME type, ukuran yang diklaim, dan metadata lain.

Arsitektur aman untuk upload file di Next.js

Arsitektur yang aman biasanya memisahkan proses upload menjadi beberapa tahap agar kontrol keamanan lebih jelas.

Alur yang direkomendasikan

  1. Client meminta izin upload ke backend dengan konteks pengguna yang sudah login.
  2. Server memvalidasi hak akses, tipe file yang diizinkan, dan batas ukuran.
  3. File diunggah ke route handler Next.js atau langsung ke object storage melalui signed URL, tergantung kebutuhan.
  4. File disimpan di lokasi karantina dengan nama acak, bukan nama asli dari pengguna.
  5. Background worker melakukan scanning antivirus dan validasi lanjutan.
  6. Status file ditandai sebagai pending, clean, atau rejected.
  7. Aplikasi hanya menyajikan file yang sudah berstatus aman.

Kapan memakai route handler vs signed URL

Route handler/API Next.js cocok jika:

  • ukuran file relatif kecil sampai menengah,
  • Anda perlu validasi sinkron sebelum file diterima,
  • jumlah trafik upload belum terlalu besar.

Signed URL ke object storage lebih cocok jika:

  • file besar atau trafik tinggi,
  • Anda ingin mengurangi beban server aplikasi,
  • arsitektur sudah memakai storage seperti S3-compatible object storage.

Trade-off-nya, route handler memberi kontrol lebih awal yang lebih sederhana, sedangkan signed URL lebih efisien untuk skala besar tetapi membutuhkan orkestrasi tambahan untuk verifikasi dan scanning setelah upload selesai.

Prinsip keamanan yang wajib diterapkan

1. Validasi server-side, bukan hanya client-side

Validasi di frontend hanya membantu pengalaman pengguna. Penegakan aturan harus terjadi di server. Minimal, periksa:

  • ukuran file aktual,
  • allowlist MIME dan/atau signature file,
  • ekstensi yang konsisten dengan tipe file,
  • apakah pengguna berhak mengunggah file itu.

2. Gunakan allowlist, bukan blocklist

Lebih aman mengatakan “hanya izinkan PNG, JPEG, dan PDF” daripada “tolak EXE dan JS”. Blocklist mudah dilewati karena format berbahaya sangat banyak dan terus bertambah.

3. Jangan gunakan nama file dari pengguna sebagai nama penyimpanan

Simpan file dengan identifier acak, misalnya UUID atau token kriptografis. Nama asli boleh disimpan hanya sebagai metadata jika memang diperlukan untuk tampilan UI, tetapi jangan dipakai sebagai path file.

4. Simpan file di storage terisolasi

Jangan menaruh hasil upload di direktori yang bisa dieksekusi server atau bercampur dengan aset aplikasi. Idealnya:

  • gunakan object storage terpisah,
  • pisahkan bucket/container untuk file mentah dan file yang lolos scan,
  • hindari public read default,
  • sajikan file melalui domain/CDN terpisah bila perlu.

5. Batasi ukuran dan jumlah upload

Pembatasan ukuran harus dilakukan sedini mungkin. Jika platform Anda memungkinkan, tolak request besar sebelum seluruh file dibaca ke memori. Selain ukuran per file, pertimbangkan juga:

  • batas jumlah file per request,
  • batas total ukuran per pengguna per periode waktu,
  • batas paralel upload.

6. Jangan ekstrak arsip secara otomatis tanpa sandbox

ZIP bomb biasanya baru berbahaya saat diekstrak. Jika aplikasi memang harus menerima arsip, ekstraksi harus dilakukan oleh worker terisolasi dengan:

  • batas ukuran hasil ekstraksi,
  • batas jumlah file di dalam arsip,
  • batas kedalaman nested archive,
  • timeout proses.

7. Gunakan autentikasi, otorisasi, dan rate limit

Endpoint upload tanpa kontrol akses adalah target empuk untuk abuse. Terapkan:

  • auth wajib untuk upload normal,
  • otorisasi berdasarkan peran atau quota,
  • rate limit per user, per IP, atau kombinasi keduanya,
  • proteksi tambahan untuk endpoint publik, misalnya challenge atau approval flow.

Contoh implementasi route handler Next.js yang aman secara dasar

Contoh berikut menunjukkan pola dasar upload di route handler. Tujuannya bukan menjadi solusi final untuk semua kasus, melainkan fondasi yang benar: validasi server-side, batas ukuran, nama file acak, dan penyimpanan karantina.

Catatan: contoh ini memakai API Web request.formData() dan memproses file dari memori. Untuk file besar, pendekatan streaming atau direct-to-storage biasanya lebih tepat agar penggunaan memori tetap terkendali.

import { randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_MIME = new Set([
  'image/png',
  'image/jpeg',
  'application/pdf'
]);

function sanitizeOriginalName(name) {
  return name.replace(/[^a-zA-Z0-9._-]/g, '_');
}

function getExtension(name) {
  const ext = path.extname(name || '').toLowerCase();
  return ext;
}

function detectBasicSignature(buffer) {
  if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
    return 'image/png';
  }

  if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
    return 'image/jpeg';
  }

  if (buffer.length >= 4 && buffer.subarray(0, 4).equals(Buffer.from('%PDF'))) {
    return 'application/pdf';
  }

  return 'unknown';
}

function extensionMatchesMime(ext, mime) {
  const map = {
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.pdf': 'application/pdf'
  };
  return map[ext] === mime;
}

export async function POST(request) {
  try {
    // TODO: verifikasi session/JWT di sini
    // TODO: terapkan rate limit sebelum parsing body jika memungkinkan

    const formData = await request.formData();
    const file = formData.get('file');

    if (!(file instanceof File)) {
      return Response.json({ error: 'File tidak ditemukan' }, { status: 400 });
    }

    if (file.size === 0) {
      return Response.json({ error: 'File kosong tidak diizinkan' }, { status: 400 });
    }

    if (file.size > MAX_FILE_SIZE) {
      return Response.json({ error: 'Ukuran file melebihi batas' }, { status: 413 });
    }

    if (!ALLOWED_MIME.has(file.type)) {
      return Response.json({ error: 'Tipe file tidak diizinkan' }, { status: 415 });
    }

    const originalName = sanitizeOriginalName(file.name || 'unknown');
    const ext = getExtension(originalName);

    if (!extensionMatchesMime(ext, file.type)) {
      return Response.json({ error: 'Ekstensi file tidak sesuai dengan tipe yang diizinkan' }, { status: 400 });
    }

    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    const detectedMime = detectBasicSignature(buffer);
    if (detectedMime !== file.type) {
      return Response.json({ error: 'Konten file tidak sesuai dengan MIME yang dikirim' }, { status: 400 });
    }

    const storageId = randomUUID();
    const quarantineDir = path.join(process.cwd(), 'var', 'uploads-quarantine');
    await fs.mkdir(quarantineDir, { recursive: true });

    const storedFilename = `${storageId}${ext}`;
    const storedPath = path.join(quarantineDir, storedFilename);

    await fs.writeFile(storedPath, buffer, { flag: 'wx' });

    // Simpan metadata ke database dengan status: pending_scan
    // contoh field: id, ownerId, originalName, storedFilename, mime, size, status

    return Response.json({
      id: storageId,
      status: 'pending_scan'
    }, { status: 202 });
  } catch (error) {
    console.error('upload_error', error);
    return Response.json({ error: 'Gagal memproses upload' }, { status: 500 });
  }
}

Mengapa pendekatan ini lebih aman?

  • Ukuran diperiksa di server, sehingga klien tidak bisa melewati batas hanya dengan memodifikasi frontend.
  • MIME type tidak dipercaya mentah-mentah; ia dibandingkan dengan signature file dasar.
  • Nama file asli tidak dipakai sebagai path, sehingga risiko path traversal turun drastis.
  • Status file dibuat pending, jadi file belum dianggap aman sebelum scanning selesai.

Keterbatasan contoh di atas

  • Pemeriksaan signature dasar hanya cocok untuk beberapa format sederhana.
  • Pemrosesan ke memori tidak ideal untuk file besar.
  • Belum ada integrasi rate limit, auth, database, dan antivirus sungguhan.
  • Belum ada mekanisme cleanup untuk file yang gagal scan atau orphaned upload.

Validasi metadata dan konten: apa yang sebaiknya diperiksa

Metadata minimum

  • Ukuran aktual: wajib dibandingkan dengan batas sistem.
  • MIME dari klien: berguna sebagai sinyal awal, bukan sumber kebenaran.
  • Ekstensi: harus konsisten dengan tipe yang diizinkan.
  • Jumlah file: batasi bila endpoint menerima multi-upload.

Konten dasar

Untuk tipe umum seperti PNG, JPEG, dan PDF, Anda bisa memeriksa magic number/header. Ini tidak menggantikan antivirus, tetapi efektif untuk menolak file yang jelas-jelas tidak sesuai.

Jika Anda menerima dokumen Office, arsip, atau format kompleks lain, validasi akan jauh lebih sulit. Dalam kasus itu, lebih aman membatasi format yang diterima kecuali memang ada kebutuhan bisnis yang jelas.

Jangan bergantung pada ekstensi saja

Kesalahan umum adalah hanya mengecek .jpg atau .pdf. Ekstensi mudah diubah dan tidak memberi jaminan apa pun tentang isi file.

Strategi antivirus dan asynchronous scanning

Di sistem produksi, scanning sebaiknya dipisahkan dari request upload utama. Alasannya sederhana: scan bisa lambat, gagal, atau memerlukan dependency sistem yang tidak ideal dijalankan di request path.

Pola yang direkomendasikan

  1. File diterima dan disimpan ke karantina.
  2. Metadata file masuk ke database dengan status pending_scan.
  3. Job queue mengirim tugas ke worker scanner.
  4. Worker menjalankan antivirus atau scanner konten.
  5. Jika lolos, file dipindahkan atau ditandai sebagai clean.
  6. Jika gagal, file ditandai rejected lalu dihapus atau disimpan terbatas untuk investigasi.

Kenapa asinkron lebih baik?

  • Request lebih cepat: pengguna tidak menunggu proses scan selesai.
  • Lebih andal: kegagalan scanner tidak langsung memutus seluruh API upload.
  • Lebih mudah diskalakan: worker scan bisa ditambah terpisah dari aplikasi web.

Contoh alur status file

pending_upload -> pending_scan -> clean
                             └-> rejected

Catatan implementasi antivirus

Antivirus bisa dijalankan melalui service terisolasi, container scanner, atau worker khusus. Hindari menjalankan proses scan dengan hak akses berlebihan. Scanner juga perlu timeout, logging, dan pemantauan agar file tidak menggantung di status pending_scan selamanya.

Prinsip penting: file yang belum selesai discan harus diperlakukan sebagai file tidak aman. Jangan sajikan ke pengguna akhir dan jangan proses lebih lanjut sebelum statusnya jelas.

Penyimpanan terisolasi dan signed URL

Penyimpanan terisolasi

Gunakan storage yang tidak mengeksekusi file. Untuk object storage, pisahkan minimal dua area:

  • quarantine bucket/prefix untuk file mentah,
  • clean bucket/prefix untuk file yang sudah lolos scan.

Keuntungannya, kontrol akses menjadi lebih mudah. Aplikasi atau CDN hanya diberi akses baca ke area clean, bukan ke seluruh storage.

Kapan signed URL relevan?

Jika file besar atau upload sangat sering, signed URL berguna agar browser mengunggah langsung ke object storage. Tetapi tetap ada aturan keamanan:

  • backend yang membuat signed URL harus memvalidasi pengguna dan batas file,
  • scope signed URL harus sempit: satu object key, metode tertentu, dan masa berlaku singkat,
  • nama object tetap harus acak,
  • setelah upload selesai, backend tetap perlu memverifikasi metadata dan memicu scan.

Signed URL bukan pengganti validasi. Ia hanya memindahkan jalur transfer data agar lebih efisien.

Abuse guard: rate limit, auth, quota, dan logging

Autentikasi dan otorisasi

Jangan buka endpoint upload untuk publik kecuali memang diperlukan. Untuk aplikasi internal atau SaaS, upload sebaiknya mensyaratkan session atau token yang valid. Selain itu, periksa apakah pengguna:

  • punya hak akses ke resource tujuan,
  • belum melebihi quota penyimpanan,
  • boleh mengunggah tipe file tertentu.

Rate limit

Rate limit membantu melawan flood dan abuse biaya. Implementasi praktis biasanya berbasis:

  • per IP untuk endpoint publik,
  • per user ID untuk endpoint yang butuh login,
  • kombinasi keduanya untuk hasil yang lebih stabil.

Jika menggunakan reverse proxy atau CDN di depan aplikasi, pastikan identitas IP klien dibaca dengan benar. Salah konfigurasi di sini sering membuat seluruh trafik terlihat berasal dari satu IP yang sama.

Quota

Rate limit mengontrol frekuensi, tetapi tidak selalu mengontrol biaya. Tambahkan quota seperti:

  • maksimum total storage per user,
  • maksimum upload per hari,
  • maksimum file pending scan.

Logging dan audit trail

Catat peristiwa penting tanpa membocorkan informasi sensitif. Minimal log berikut berguna:

  • waktu upload,
  • user ID atau identitas pemilik,
  • IP atau identitas jaringan,
  • ukuran file,
  • MIME klien dan hasil deteksi dasar,
  • status scan,
  • alasan penolakan.

Log ini membantu investigasi penyalahgunaan, debugging false positive, dan analisis pola serangan.

Respons error yang aman

Pesan error harus cukup informatif untuk klien, tetapi tidak terlalu detail sampai membantu penyerang. Contohnya:

  • Aman: “Tipe file tidak diizinkan”, “Ukuran file melebihi batas”, “Upload gagal diproses”.
  • Terlalu detail: path internal file, stack trace, nama engine antivirus, aturan scanner spesifik, struktur bucket storage.

Bedakan juga status HTTP secara masuk akal:

  • 400 untuk request invalid,
  • 401 atau 403 untuk masalah auth/otorisasi,
  • 413 untuk payload terlalu besar,
  • 415 untuk media type tidak didukung,
  • 429 untuk rate limit,
  • 500 untuk kegagalan internal.

Jika file harus discan secara asinkron, respons 202 Accepted sering lebih tepat daripada 200 OK, karena file belum final tersedia.

Checklist testing untuk upload file aman di Next.js

Gunakan checklist berikut sebelum menganggap fitur upload siap produksi.

Pengujian validasi file

  • Upload file valid sesuai allowlist.
  • Upload file dengan ekstensi benar tetapi isi salah.
  • Upload file dengan MIME klien palsu.
  • Upload file kosong.
  • Upload file melebihi batas ukuran.
  • Upload banyak file sekaligus jika endpoint mendukung multi-upload.

Pengujian nama file dan path

  • Nama file dengan karakter aneh, unicode, spasi panjang, dan simbol shell.
  • Nama file dengan pola path traversal seperti ../.
  • Nama file sangat panjang.

Pengujian abuse

  • Flood request untuk memastikan rate limit bekerja.
  • Upload berulang dengan akun yang sama untuk menguji quota.
  • Request tanpa auth atau token kadaluarsa.

Pengujian scanning

  • Pastikan file pending_scan tidak bisa diakses publik.
  • Simulasikan scanner timeout atau crash.
  • Pastikan file berstatus rejected tidak tampil di aplikasi.
  • Uji cleanup untuk file gagal scan atau job yang tidak selesai.

Pengujian observability

  • Log alasan penolakan tercatat dengan konsisten.
  • Metrik jumlah upload, reject, dan error bisa dipantau.
  • Tidak ada data sensitif bocor ke log atau response API.

Kesalahan umum yang harus dihindari

  • Hanya validasi di frontend. Ini mudah dilewati dengan cURL, Postman, atau script.
  • Percaya penuh pada file.type atau header Content-Type. Nilai ini berasal dari klien.
  • Menyimpan file dengan nama asli pengguna. Ini membuka masalah path, collision, dan audit yang buruk.
  • Menaruh hasil upload di direktori publik aplikasi. File mentah bisa terekspos sebelum lolos scan.
  • Menganggap antivirus cukup sendirian. Scanner penting, tetapi bukan pengganti allowlist, size limit, auth, dan rate limit.
  • Mengekstrak ZIP tanpa batas resource. Ini pintu masuk klasik untuk ZIP bomb.
  • Tidak punya status lifecycle file. Tanpa status seperti pending_scan dan clean, aplikasi mudah salah menyajikan file.
  • Tidak membersihkan file yatim. Upload yang gagal atau abandoned bisa menumpuk dan menambah biaya storage.

Penutup

Upload file aman di Next.js untuk produksi membutuhkan lebih dari sekadar form upload dan pengecekan ekstensi. Lapisan minimum yang sebaiknya selalu ada adalah validasi server-side, allowlist tipe file, batas ukuran, nama file acak, storage terisolasi, auth, rate limit, logging, dan scanning asinkron sebelum file dipakai.

Jika Anda ingin mulai secara bertahap, urutan prioritas yang masuk akal adalah: (1) validasi server-side dan size limit, (2) nama file acak dan storage karantina, (3) auth plus rate limit, lalu (4) asynchronous malware scanning dan workflow status file. Dengan urutan ini, Anda sudah menutup sebagian besar celah praktis yang paling sering dieksploitasi pada endpoint upload.