Fitur upload file di Next.js tidak aman hanya karena Anda membatasi ekstensi di form. Penyerang bisa mengirim MIME type palsu, mengganti ekstensi file, mengirim file terlalu besar, mencoba menimpa file lama, atau memanfaatkan sesi berbasis cookie untuk serangan CSRF. Karena itu, upload aman di Next.js harus diputuskan di sisi server, bukan di UI.
Pendekatan yang aman untuk App Router atau Route Handler adalah: terima file di endpoint server, validasi ukuran dan tipe berdasarkan allowlist, cek konten file secara server-side, gunakan nama file acak, simpan file di luar public/, terapkan proteksi CSRF bila autentikasi memakai cookie, dan tambahkan rate limit per user/IP untuk mencegah burst request. Dengan arsitektur ini, risiko upload berbahaya turun jauh tanpa membuat alur aplikasi terlalu rumit.
Ancaman utama pada fitur upload file
1. Spoofed MIME type
Browser mengirim metadata seperti file.type atau header Content-Type, tetapi nilai ini tidak boleh dipercaya sepenuhnya. Penyerang bisa mengirim file executable atau skrip dengan MIME yang terlihat seperti gambar. Jika server hanya mengecek file.type, validasi dapat dilewati.
2. Ekstensi palsu
Nama seperti invoice.pdf.exe atau avatar.jpg tidak menjamin isi file memang PDF atau JPEG. Validasi hanya berdasarkan nama file adalah kesalahan umum.
3. File terlalu besar
Upload file besar bisa menghabiskan memori, storage, atau waktu proses. Bahkan bila tidak memicu eksekusi kode, ini tetap bisa menjadi bentuk denial of service.
4. Path traversal
Jika nama file dari user dipakai langsung saat menyimpan file, input seperti ../../somewhere berpotensi mengarah ke lokasi yang tidak semestinya. Meski API tertentu membatasi hal ini, Anda tetap tidak boleh mempercayai nama file dari klien.
5. Overwrite nama file
Menyimpan file dengan nama asli pengguna berisiko menimpa file lain yang namanya sama, baik sengaja maupun tidak. Selain itu, nama file kadang mengandung karakter aneh yang menyulitkan pengelolaan.
6. CSRF pada upload berbasis cookie session
Jika endpoint upload menerima autentikasi dari cookie sesi, browser bisa ikut mengirim cookie saat request berasal dari situs lain. Tanpa proteksi CSRF, penyerang dapat memicu upload atas nama user yang sedang login.
7. Abuse lewat burst request
Meski setiap file lolos validasi, penyerang masih bisa membanjiri endpoint dengan banyak request kecil dalam waktu singkat. Karena itu, pembatasan frekuensi request tetap diperlukan.
Arsitektur aman untuk upload di Next.js
Berikut pola yang praktis dan aman untuk Route Handler di App Router:
- Endpoint upload menerima multipart/form-data dan mengambil file dari
request.formData(). - Validasi CSRF dilakukan lebih dulu bila endpoint menggunakan cookie session.
- Validasi ukuran file dilakukan sebelum pemrosesan lebih lanjut.
- Validasi tipe file memakai allowlist MIME dan, bila memungkinkan, pemeriksaan signature atau magic number dari byte awal file.
- Nama file diganti dengan identifier acak, bukan nama asli user.
- File disimpan di luar public path, misalnya direktori privat atau object storage privat.
- Metadata file seperti nama asli, ukuran, MIME hasil validasi, dan lokasi penyimpanan dicatat terpisah.
- Rate limit diterapkan per user atau IP.
Catatan: Menyimpan file langsung di
public/memudahkan akses langsung dari URL, tetapi itu bukan pilihan aman untuk file yang belum lolos proses verifikasi atau file yang seharusnya privat.
Implementasi Route Handler Next.js yang aman
Contoh berikut menunjukkan implementasi dasar di App Router. Fokusnya adalah alur validasi, bukan integrasi storage tertentu.
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { cookies, headers } from 'next/headers';
import { NextResponse } from 'next/server';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const UPLOAD_DIR = path.join(process.cwd(), 'var', 'uploads');
const ALLOWED_TYPES = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['application/pdf', 'pdf'],
]);
function getOriginHost(value) {
try {
return value ? new URL(value).host : null;
} catch {
return null;
}
}
async function verifyCsrf() {
const cookieStore = await cookies();
const headerStore = await headers();
const csrfCookie = cookieStore.get('csrf_token')?.value;
const csrfHeader = headerStore.get('x-csrf-token');
const origin = headerStore.get('origin');
const host = headerStore.get('host');
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return false;
}
const originHost = getOriginHost(origin);
if (originHost && host && originHost !== host) {
return false;
}
return true;
}
function detectFileType(buffer) {
if (buffer.length >= 4) {
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg';
}
// PNG: 89 50 4E 47
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return 'image/png';
}
// PDF: 25 50 44 46
if (
buffer[0] === 0x25 &&
buffer[1] === 0x50 &&
buffer[2] === 0x44 &&
buffer[3] === 0x46
) {
return 'application/pdf';
}
}
return null;
}
async function checkRateLimit(key) {
// Contoh stub. Implementasikan dengan Redis atau storage serupa.
// Return false jika limit terlampaui.
return true;
}
export async function POST(request) {
const headerStore = await headers();
const ip = headerStore.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
const allowed = await checkRateLimit(`upload:${ip}`);
if (!allowed) {
return NextResponse.json({ error: 'Terlalu banyak request' }, { status: 429 });
}
const csrfValid = await verifyCsrf();
if (!csrfValid) {
return NextResponse.json({ error: 'CSRF token tidak valid' }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'File tidak ditemukan' }, { status: 400 });
}
if (file.size <= 0 || file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'Ukuran file tidak valid' }, { status: 413 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const detectedMime = detectFileType(buffer);
if (!detectedMime || !ALLOWED_TYPES.has(detectedMime)) {
return NextResponse.json({ error: 'Tipe file tidak diizinkan' }, { status: 415 });
}
// Jangan gunakan nama asli pengguna untuk path penyimpanan.
const extension = ALLOWED_TYPES.get(detectedMime);
const safeFilename = `${randomUUID()}.${extension}`;
await mkdir(UPLOAD_DIR, { recursive: true });
const destination = path.join(UPLOAD_DIR, safeFilename);
await writeFile(destination, buffer, { flag: 'wx' });
return NextResponse.json({
success: true,
file: {
name: safeFilename,
mime: detectedMime,
size: file.size,
},
});
}Kenapa pendekatan ini lebih aman?
- Ukuran file dicek di server, sehingga batas tidak bisa dilewati hanya dengan memodifikasi frontend.
- Tipe file ditentukan dari isi awal file, bukan hanya dari MIME yang dikirim klien.
- Nama file diacak, sehingga tidak ada path traversal dari nama asli dan risiko overwrite menurun drastis.
- Penyimpanan di luar
public/mencegah file langsung dapat dieksekusi atau diakses via URL statis sebelum ada otorisasi yang tepat. - Flag
wxpada penulisan file membantu gagal jika nama target kebetulan sudah ada.
Validasi file yang benar: jangan hanya cek ekstensi
Urutan validasi yang disarankan
- Pastikan field memang berisi objek file.
- Tolak file kosong atau terlalu besar.
- Baca byte awal file dan deteksi signature.
- Cocokkan hasil deteksi dengan allowlist tipe file.
- Baru kemudian simpan ke storage.
Jika Anda hanya mengandalkan ekstensi, file berbahaya cukup diganti namanya. Jika Anda hanya mengandalkan file.type, nilai itu bisa dipalsukan. Pemeriksaan signature bukan solusi sempurna untuk semua format, tetapi jauh lebih kuat daripada validasi metadata saja.
Kapan perlu validasi lebih dalam?
Untuk beberapa kasus, signature file saja belum cukup. Misalnya, bila aplikasi menerima dokumen kompleks atau file yang nantinya diproses ulang oleh service lain. Dalam situasi itu, pertimbangkan validasi tambahan seperti parsing minimal, antivirus scanning, atau proses isolasi di worker terpisah. Namun untuk banyak kasus upload gambar dan PDF sederhana, kombinasi size check, allowlist, dan signature check sudah merupakan baseline yang baik.
Proteksi CSRF untuk upload berbasis cookie session
Jika endpoint upload menggunakan cookie sesi, maka proteksi CSRF wajib dipertimbangkan. Masalahnya bukan autentikasi user, tetapi fakta bahwa browser dapat otomatis mengirim cookie ke server target.
Pola yang praktis
- Saat render form upload, server membuat token CSRF acak.
- Token disimpan di cookie dan juga dikirim ke frontend untuk disertakan ke header seperti
X-CSRF-Token. - Di Route Handler, token pada cookie dibandingkan dengan token pada header atau field form.
- Tambahkan validasi
OriginatauReferersebagai lapisan tambahan.
Validasi Origin membantu menolak request lintas situs, tetapi jangan menjadikannya satu-satunya kontrol karena beberapa lingkungan bisa tidak mengirim header tersebut secara konsisten. Token tetap menjadi mekanisme utama.
Catatan: Jika Anda memakai autentikasi berbasis bearer token yang dikirim manual oleh frontend, risiko CSRF berbeda. Namun artikel ini fokus pada upload yang bergantung pada cookie session.
Mencegah abuse: rate limit per user atau IP
Validasi file tidak menghentikan abuse volumetrik. Anda tetap perlu rate limit agar satu user atau satu IP tidak bisa mengirim banyak request upload dalam waktu singkat.
Pendekatan implementasi
- Per IP cocok sebagai baseline jika user belum login.
- Per user ID lebih akurat untuk aplikasi login.
- Gunakan storage terpusat seperti Redis agar limit konsisten di banyak instance aplikasi.
Strategi umum adalah token bucket atau fixed window. Anda tidak perlu membuatnya rumit di awal; yang penting, kegagalan limit menghasilkan respons 429 Too Many Requests dan request tidak lanjut ke proses baca atau simpan file.
async function checkRateLimit(key) {
// Pseudocode:
// 1. increment counter di Redis untuk key tertentu
// 2. set expiry jika key baru
// 3. return false jika melebihi ambang
return true;
}Trade-off-nya: limit per IP bisa memblokir banyak user di belakang NAT yang sama, sedangkan limit per user tidak membantu untuk endpoint publik tanpa login. Banyak sistem akhirnya memakai kombinasi keduanya.
Penyimpanan file: hindari public path dan nama asli
Kenapa jangan simpan di public path?
Jika file langsung masuk ke public/, file tersebut bisa segera diakses sebagai aset statis. Ini berbahaya untuk file yang belum diverifikasi, file privat, atau file yang memerlukan audit lebih dulu. Menyimpan di lokasi privat memberi Anda kontrol atas siapa yang boleh mengunduh dan kapan file bisa dipublikasikan.
Kenapa nama file harus acak?
- Mencegah tabrakan nama.
- Menghilangkan risiko path traversal dari nama input pengguna.
- Mengurangi kebocoran informasi dari nama file asli.
Bila Anda tetap perlu menampilkan nama asli, simpan sebagai metadata di database, bukan sebagai nama fisik file di storage.
Alur upload yang direkomendasikan
- Frontend mengambil token CSRF dari server.
- User memilih file dan mengirim
multipart/form-data. - Route Handler mengecek rate limit.
- Server memvalidasi CSRF.
- Server membaca file, mengecek ukuran, lalu mendeteksi tipe file dari signature.
- Jika valid, server membuat nama acak dan menyimpan file ke storage privat.
- Server menyimpan metadata file.
- Server mengembalikan ID atau nama file internal, bukan path sensitif.
Kesalahan umum yang sering terjadi
- Hanya validasi di frontend. Frontend berguna untuk UX, bukan untuk keamanan.
- Mengandalkan
file.typeatau ekstensi saja. Ini mudah dipalsukan. - Menyimpan nama file asli sebagai path final. Ini membuka masalah traversal, tabrakan nama, dan sanitasi karakter.
- Menyimpan file di
public/terlalu dini. Akses file jadi terlalu mudah. - Tidak membatasi ukuran file. Endpoint menjadi target mudah untuk pemborosan resource.
- Melewatkan CSRF karena merasa user sudah login. Justru cookie login itulah yang membuat CSRF relevan.
- Tidak ada rate limit. Endpoint upload biasanya lebih mahal dibanding request biasa.
- Membaca file besar sepenuhnya ke memori tanpa batas jelas. Untuk kebutuhan skala lebih besar, pertimbangkan streaming atau direct-to-object-storage dengan validasi yang tetap terkendali di backend.
Tips debugging saat validasi gagal
- Log ukuran file, MIME dari klien, dan MIME hasil deteksi server secara terpisah.
- Bedakan status error:
400untuk input salah,403untuk CSRF,413untuk ukuran,415untuk tipe file,429untuk rate limit. - Jangan log isi file atau data sensitif secara mentah.
- Jika upload gagal di reverse proxy atau platform hosting, cek juga batas body request di infrastruktur, bukan hanya di kode Next.js.
Checklist hardening produksi
- Gunakan validasi server-side untuk semua upload.
- Terapkan allowlist tipe file yang benar-benar dibutuhkan.
- Batasi ukuran file maksimum dengan jelas.
- Deteksi tipe file dari signature/magic number bila memungkinkan.
- Gunakan nama file acak, bukan nama asli user.
- Simpan file di lokasi privat, bukan langsung di
public/. - Simpan metadata file secara terpisah dari path fisik.
- Terapkan CSRF token untuk endpoint berbasis cookie session.
- Validasi Origin/Referer sebagai lapisan tambahan.
- Terapkan rate limit per user/IP.
- Pastikan respons error tidak membocorkan path internal server.
- Audit hak akses direktori upload dan rotasi file sementara bila ada.
Penutup
Upload aman di Next.js bukan soal satu validasi tunggal, melainkan gabungan beberapa kontrol yang saling melengkapi. Jika Anda hanya memeriksa ekstensi di frontend, sistem masih mudah dilewati. Baseline yang layak untuk produksi adalah validasi server-side, allowlist tipe file, pembatasan ukuran, nama file acak, penyimpanan di luar public path, proteksi CSRF untuk sesi berbasis cookie, dan rate limit untuk menahan abuse.
Mulailah dari kontrol yang paling berdampak: cek ukuran, deteksi tipe file dari isi, ganti nama file, dan jangan simpan ke jalur publik. Setelah itu, tambahkan CSRF dan rate limit agar endpoint upload tetap aman saat berhadapan dengan traffic nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!