Validasi upload file aman di Laravel tidak cukup hanya dengan aturan required|file. Di aplikasi produksi, Anda perlu memikirkan beberapa risiko sekaligus: file dengan MIME palsu, ekstensi berbahaya, ukuran file berlebihan, nama file yang dimanipulasi, penyimpanan di lokasi publik, dan kemungkinan file diakses atau dieksekusi tanpa sengaja.

Pendekatan yang aman biasanya terdiri dari beberapa lapisan: validasi request, pembatasan tipe file berdasarkan MIME dan ekstensi, pembatasan ukuran, penamaan file baru yang tidak bergantung pada input pengguna, penyimpanan di disk terpisah, dan aturan infrastruktur agar file upload tidak bisa dieksekusi sebagai skrip. Jika file berasal dari pengguna eksternal atau dipakai lintas organisasi, pertimbangkan juga antivirus scanning dan pemrosesan async.

Mengapa upload file adalah area berisiko

Upload file terlihat sederhana, tetapi permukaan serangannya cukup besar. Penyerang bisa mencoba mengunggah file yang menyamar sebagai gambar, memasukkan nama file dengan karakter aneh, mengirim file sangat besar untuk menghabiskan resource server, atau memanfaatkan penyimpanan publik agar file dapat diakses langsung.

  • Spoofed MIME type: client mengirim header atau metadata yang mengklaim file adalah image/png, padahal isinya berbeda.
  • Ekstensi berbahaya: file seperti shell.php.jpg atau ekstensi yang tidak seharusnya diterima.
  • File terlalu besar: bisa memicu kegagalan upload, penggunaan disk berlebih, atau beban worker.
  • Path traversal: nama file atau path yang mencoba keluar dari direktori target.
  • Nama file bentrok: file lama tertimpa karena menggunakan nama asli yang sama.
  • Penyimpanan publik yang tidak perlu: file sensitif tersimpan di lokasi yang bisa diakses langsung melalui web server.

Tujuan utamanya bukan hanya menerima file yang valid, tetapi memastikan file tersebut disimpan, diakses, dan diproses dengan cara yang aman.

Prinsip dasar validasi upload file aman di Laravel

1. Jangan percaya nama file asli dari client

Nama file dari browser adalah input tidak tepercaya. Jangan gunakan langsung untuk path penyimpanan. Selain rawan bentrok, nama file bisa mengandung karakter yang merepotkan, pola traversal, atau format yang tidak konsisten.

Gunakan nama file baru yang dibuat server, misalnya UUID, hash, atau kombinasi timestamp dan random string. Nama asli masih boleh disimpan di database sebagai metadata, tetapi jangan dijadikan nama file final di storage.

2. Validasi tipe file dengan pendekatan berlapis

Mengandalkan ekstensi saja tidak cukup, tetapi mengandalkan MIME dari client juga tidak cukup. Praktik yang lebih aman adalah:

  • Batasi jenis file yang diizinkan.
  • Validasi MIME yang dideteksi server.
  • Validasi ekstensi sebagai lapisan tambahan.
  • Untuk tipe tertentu seperti gambar, gunakan validasi khusus seperti image bila memang hanya menerima gambar.

Tujuannya bukan membuat validasi sempurna secara absolut, melainkan memperkecil peluang file berbahaya lolos sebagai format yang diizinkan.

3. Simpan file di lokasi non-publik secara default

Jika file tidak memang harus diakses langsung oleh browser, simpan di disk privat. Akses file dilakukan melalui controller atau signed URL yang Anda kontrol. Ini penting untuk dokumen internal, file identitas, lampiran tiket, atau arsip pengguna.

Storage publik sebaiknya hanya dipakai untuk aset yang memang aman dipublikasikan, misalnya avatar yang sudah divalidasi ketat dan tidak sensitif.

Implementasi validasi request di Laravel

Gunakan Form Request agar aturan validasi terpusat dan mudah diuji. Contoh berikut menerima dokumen PDF atau gambar umum dengan batas ukuran tertentu.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreUploadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'file' => [
                'required',
                'file',
                'max:5120', // dalam KB, 5 MB
                'mimetypes:image/jpeg,image/png,application/pdf',
                'mimes:jpg,jpeg,png,pdf',
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'file.required' => 'File wajib diunggah.',
            'file.file' => 'Input harus berupa file yang valid.',
            'file.max' => 'Ukuran file melebihi batas 5 MB.',
            'file.mimetypes' => 'Tipe file tidak diizinkan.',
            'file.mimes' => 'Ekstensi file tidak diizinkan.',
        ];
    }
}

Beberapa catatan penting:

  • max pada validasi file umumnya dihitung dalam kilobyte.
  • mimetypes membantu membatasi berdasarkan MIME type.
  • mimes membantu membatasi ekstensi yang diizinkan.
  • Menggabungkan keduanya memberi lapisan validasi tambahan.

Catatan: pemeriksaan MIME bukan jaminan absolut bahwa file aman. Untuk file berisiko tinggi atau lingkungan dengan kebutuhan kepatuhan tertentu, tambahkan scanning atau analisis file setelah upload.

Kapan memakai image?

Jika endpoint memang hanya menerima gambar, gunakan aturan yang lebih spesifik seperti image dan tetap batasi ukuran. Anda juga bisa menambahkan validasi dimensi bila relevan. Ini lebih ketat dibanding menerima berbagai tipe file sekaligus.

'avatar' => ['required', 'image', 'max:2048']

Namun, jika aplikasi menerima campuran gambar dan dokumen, pendekatan mimetypes + mimes lebih fleksibel.

Controller: simpan file dengan nama aman dan disk terpisah

Setelah lolos validasi, simpan file menggunakan nama baru yang dibuat server. Hindari getClientOriginalName() sebagai nama file final. Simpan juga metadata penting agar aplikasi tetap bisa menampilkan informasi kepada pengguna.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreUploadRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class UploadController extends Controller
{
    public function store(StoreUploadRequest $request): JsonResponse
    {
        $uploadedFile = $request->file('file');

        $extension = strtolower($uploadedFile->getClientOriginalExtension());
        $safeFilename = (string) Str::uuid() . '.' . $extension;

        $path = $uploadedFile->storeAs(
            'documents/' . date('Y/m'),
            $safeFilename,
            'private_uploads'
        );

        // Simpan metadata ke database jika diperlukan
        // Misalnya: original_name, stored_path, mime_type, size, uploaded_by

        return response()->json([
            'message' => 'File berhasil diunggah.',
            'data' => [
                'path' => $path,
                'original_name' => $uploadedFile->getClientOriginalName(),
                'mime_type' => $uploadedFile->getMimeType(),
                'size' => $uploadedFile->getSize(),
            ],
        ], 201);
    }
}

Mengapa pola ini lebih aman:

  • Nama file unik: menghindari bentrok dan overwrite file lama.
  • Path ditentukan server: pengguna tidak bisa memilih direktori penyimpanan.
  • Disk privat: file tidak otomatis bisa diakses publik.
  • Metadata tersimpan: Anda tetap bisa menampilkan nama asli tanpa menjadikannya bagian dari path.

Jangan bangun path dari input pengguna

Kesalahan umum adalah menerima parameter folder dari request lalu menggabungkannya langsung ke path penyimpanan. Ini bisa membuka peluang path traversal atau penyimpanan file di lokasi yang tidak seharusnya.

Lebih aman jika path selalu dibentuk dari aturan server, misalnya kategori internal, ID tenant, atau struktur tanggal yang Anda kontrol sepenuhnya.

Konfigurasi filesystem untuk storage terpisah

Untuk aplikasi produksi, pisahkan disk upload dari disk publik umum. Jika memungkinkan, simpan file user upload di storage privat atau object storage dengan akses terbatas.

'disks' => [
    'private_uploads' => [
        'driver' => 'local',
        'root' => storage_path('app/private_uploads'),
        'throw' => false,
    ],

    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
        'throw' => false,
    ],
]

Dengan disk seperti private_uploads, file tidak terpapar otomatis melalui web server. Untuk mengunduh file, buat endpoint yang memeriksa otorisasi terlebih dahulu.

public function download(string $path)
{
    abort_unless(Storage::disk('private_uploads')->exists($path), 404);

    // Tambahkan pemeriksaan authorization sesuai kebutuhan

    return Storage::disk('private_uploads')->download($path);
}

Jika Anda menggunakan object storage, prinsipnya tetap sama: pisahkan bucket atau prefix privat dan jangan membuat semua upload dapat diakses publik tanpa kebutuhan yang jelas.

Larangan mengeksekusi file upload

Selain aturan aplikasi, pastikan infrastruktur tidak mengeksekusi file yang diunggah pengguna. Ini sangat penting bila ada kemungkinan file tersimpan di lokasi yang pernah di-serve web server.

  • Jangan simpan upload di direktori yang diproses sebagai kode aplikasi.
  • Jangan izinkan file upload berada di folder yang bisa mengeksekusi PHP, CGI, atau skrip lain.
  • Jika memakai web server atau reverse proxy, pastikan direktori upload diperlakukan sebagai file statis biasa, bukan executable content.

Ini adalah lapisan pertahanan penting bila validasi aplikasi gagal atau ada file yang lolos dengan isi tak terduga.

Pola respons error yang jelas untuk API dan web

Upload file sering gagal karena ukuran berlebih, tipe tidak sesuai, atau batas server terlampaui. Pesan error yang jelas memudahkan debugging di frontend dan mengurangi kebingungan pengguna.

Contoh respons error JSON

{
  "message": "Validasi gagal.",
  "errors": {
    "file": [
      "Tipe file tidak diizinkan.",
      "Ukuran file melebihi batas 5 MB."
    ]
  }
}

Praktik yang disarankan:

  • Gunakan pesan validasi yang spesifik.
  • Jangan bocorkan path internal server atau detail stack trace.
  • Bedakan error validasi dengan error penyimpanan atau error scanning.

Membedakan error aplikasi dan error server

Jika file terlalu besar, kadang request gagal bahkan sebelum mencapai validasi Laravel karena batas di level PHP, web server, atau proxy. Gejalanya bisa berupa status code berbeda atau field file tidak terbaca sama sekali.

Saat debugging, cek beberapa lapisan berikut:

  • batas ukuran request pada web server atau proxy,
  • batas upload di runtime PHP,
  • aturan validasi Laravel seperti max,
  • kapasitas disk atau hak akses direktori storage.

Ini kesalahan yang sering membingungkan tim karena kode validasi terlihat benar, tetapi request sudah ditolak lebih dulu oleh lapisan di bawahnya.

MIME vs extension: mengapa keduanya perlu diperiksa

Pemeriksaan ekstensi berguna karena sederhana dan mudah dipahami, tetapi ekstensi mudah dimanipulasi. Pemeriksaan MIME lebih baik karena mencoba mengenali tipe file, tetapi hasilnya tetap bisa dipengaruhi oleh isi file dan konteks deteksi. Karena itu, pemeriksaan yang lebih aman adalah kombinasi keduanya.

Contoh file bernama invoice.pdf belum tentu benar-benar PDF. Sebaliknya, file yang dideteksi sebagai PDF tetapi berekstensi aneh juga patut dicurigai. Dengan membatasi MIME dan ekstensi sekaligus, Anda menutup dua jalur manipulasi yang umum.

Untuk kasus dengan kebutuhan keamanan lebih tinggi, pertimbangkan validasi tambahan berbasis parsing konten file. Misalnya, gambar dibuka ulang melalui library image processing, atau PDF diperiksa strukturnya sebelum dipakai lebih lanjut.

Kapan perlu antivirus scanning atau async processing

Tidak semua aplikasi perlu antivirus scanning. Namun, scanning layak dipertimbangkan jika:

  • file diunggah oleh pengguna eksternal atau anonim,
  • aplikasi menerima dokumen kantor, arsip, atau file dari banyak organisasi,
  • file akan dibagikan ke pengguna lain,
  • ada kebutuhan compliance atau audit keamanan.

Scanning sebaiknya dilakukan secara async bila ukuran file bisa besar atau engine scanning cukup berat. Polanya biasanya seperti ini:

  1. Upload file ke storage privat.
  2. Simpan status awal, misalnya pending_scan.
  3. Kirim job ke queue untuk scanning atau pemrosesan lanjutan.
  4. Jika lolos, ubah status menjadi ready.
  5. Jika gagal, tandai sebagai rejected dan blok akses file.

Pendekatan async mengurangi waktu tunggu request dan mencegah timeout. Trade-off-nya, file tidak langsung bisa dipakai sampai job selesai. Ini cocok untuk aplikasi produksi yang lebih mementingkan keamanan dan stabilitas.

Kesalahan umum yang sering terjadi

  • Menyimpan di disk publik tanpa alasan: semua file langsung bisa diakses URL publik.
  • Menggunakan nama file asli: rawan bentrok, karakter aneh, dan overwrite.
  • Hanya memeriksa ekstensi: file berbahaya bisa lolos dengan nama palsu.
  • Hanya memeriksa MIME dari client: metadata dapat dimanipulasi.
  • Menerima semua tipe file lalu memfilter belakangan: terlalu longgar pada tahap awal.
  • Membangun path dari input user: membuka risiko traversal atau penyimpanan di folder yang salah.
  • Tidak memikirkan batas upload di server: validasi Laravel tidak akan menolong jika request sudah ditolak lebih dulu.
  • Mengeksekusi atau memproses file sebelum lolos validasi: memperbesar dampak jika file berbahaya lolos.

Checklist hardening upload file di Laravel

  • Batasi endpoint hanya untuk user yang berwenang.
  • Gunakan Form Request untuk aturan validasi yang konsisten.
  • Validasi dengan file, max, mimetypes, dan mimes sesuai kebutuhan.
  • Gunakan image bila endpoint memang hanya menerima gambar.
  • Jangan gunakan nama file asli sebagai nama final di storage.
  • Buat nama file unik di server, misalnya UUID.
  • Simpan file di disk privat secara default.
  • Jangan bangun path penyimpanan dari input pengguna.
  • Simpan metadata penting di database: nama asli, path internal, ukuran, MIME, uploader, status.
  • Pastikan direktori upload tidak mengeksekusi file sebagai skrip.
  • Terapkan otorisasi saat download atau preview file privat.
  • Tangani error validasi dan error storage dengan respons yang jelas.
  • Pertimbangkan queue untuk scanning, thumbnailing, atau parsing file.
  • Pertimbangkan antivirus scanning untuk file dari sumber tidak tepercaya.

Penutup

Validasi upload file aman di Laravel adalah kombinasi antara validasi input, strategi penyimpanan, dan kontrol infrastruktur. Aturan seperti mimetypes, mimes, dan max penting, tetapi nilainya baru terasa jika dipadukan dengan nama file aman, disk privat, path yang dikontrol server, dan larangan eksekusi file upload.

Jika Anda sedang membangun aplikasi produksi, mulai dari baseline yang ketat: whitelist tipe file, ukuran terbatas, storage privat, dan respons error yang jelas. Setelah itu, tambahkan scanning async atau pipeline pemrosesan bila profil risiko aplikasi Anda memang menuntutnya.