Validasi upload file aman di Laravel harus dilakukan di server, bukan hanya di browser. Ancaman yang umum bukan sekadar file dengan ekstensi salah, tetapi juga MIME spoofing, ekstensi ganda seperti invoice.pdf.php, file terlalu besar, nama file berbahaya, path traversal, file yang langsung terekspos di folder public, sampai upload berulang untuk menghabiskan storage dan resource aplikasi.

Prinsip yang paling aman adalah defense in depth: validasi input, verifikasi tipe file, batasi ukuran, ganti nama file, simpan di disk non-public, kontrol akses saat download, tambahkan rate limit, log kejadian penting, dan bila perlu lakukan scanning malware secara asinkron. Tidak ada satu lapisan yang cukup sendiri, dan selalu ada trade-off antara keamanan, performa, dan kompleksitas operasional.

Mengapa validasi client tidak cukup

Validasi di sisi client seperti atribut accept pada input file atau pengecekan JavaScript hanya berguna untuk pengalaman pengguna. Itu bukan kontrol keamanan. Request dapat dikirim ulang dengan alat lain seperti cURL, Postman, skrip otomatis, atau browser yang dimodifikasi.

Contoh masalah yang sering terjadi:

  • Client membatasi .jpg, tetapi penyerang mengirim file lain dengan header atau nama yang dimanipulasi.
  • Ukuran file dicek di JavaScript, tetapi server tetap menerima file yang lebih besar.
  • Nama file asli dipakai apa adanya, sehingga karakter aneh, path traversal, atau tabrakan nama menjadi masalah.

Gunakan validasi client untuk UX, tetapi anggap semua file yang tiba di server sebagai input tidak tepercaya.

Ancaman nyata pada upload file

1. MIME spoofing dan ekstensi palsu

File bisa dinamai gambar.jpg padahal isinya bukan gambar. Bahkan header Content-Type dari request juga bisa dipalsukan. Karena itu, jangan percaya hanya pada ekstensi atau nilai MIME dari client.

2. Ekstensi ganda

Nama seperti laporan.pdf.php atau foto.jpg.phtml bisa lolos jika aplikasi hanya memeriksa bagian nama tertentu. Ini berbahaya terutama bila file disimpan di lokasi yang bisa dieksekusi web server.

3. File terlalu besar

File besar dapat menyebabkan masalah performa, memenuhi disk, memperlambat worker, atau memicu kegagalan request. Pembatasan ukuran perlu diterapkan berlapis: validasi aplikasi, konfigurasi PHP, dan bila perlu di reverse proxy/web server.

4. Nama file berbahaya

Nama file asli bisa berisi karakter kontrol, spasi berlebihan, karakter Unicode yang membingungkan, atau pola seperti ../../secret.txt. Walaupun helper framework biasanya membantu, praktik terbaik adalah tidak menggunakan nama asli sebagai path final.

5. Public exposure

Menyimpan upload langsung di folder yang dapat diakses publik memudahkan kebocoran file sensitif. Ini sering terjadi ketika file disimpan di disk public tanpa kontrol akses yang jelas.

6. Malware dan file berbahaya

Aplikasi umum tidak mampu memastikan file benar-benar aman hanya dengan validasi dasar. Bila use case Anda menerima file dari pihak luar, terutama dokumen kantor, arsip, atau file yang nanti dibagikan ke pengguna lain, risiko malware perlu dipertimbangkan.

7. Abuse upload berulang

Penyerang dapat mengupload berkali-kali untuk menghabiskan quota storage, bandwidth, CPU, atau antrean scan. Bahkan file valid sekalipun tetap bisa menjadi vektor abuse bila tidak ada rate limit dan monitoring.

Pola hardening yang disarankan di Laravel

Validasi request di server

Gunakan Form Request agar aturan validasi terpusat dan mudah diuji. Untuk file, fokus pada:

  • required/file untuk memastikan input benar-benar file.
  • mimes atau mimetypes sebagai lapisan awal pembatasan tipe.
  • max untuk membatasi ukuran.
  • Aturan lain sesuai konteks, misalnya hanya gambar atau hanya PDF.

Contoh request validation:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'document' => [
                'required',
                'file',
                'mimes:pdf,jpg,jpeg,png',
                'max:5120',
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'document.mimes' => 'Tipe file tidak diizinkan.',
            'document.max' => 'Ukuran file melebihi batas 5 MB.',
        ];
    }
}

Aturan di atas berguna, tetapi perlu dipahami keterbatasannya: validasi ini adalah filter awal, bukan pemeriksaan keamanan menyeluruh terhadap isi file.

Validasi MIME/content, bukan hanya ekstensi

Untuk use case yang sensitif, lakukan pemeriksaan tambahan terhadap tipe file yang terdeteksi server. Tujuannya bukan sekadar membaca nama file, tetapi memeriksa karakteristik kontennya. Praktik ini membantu mengurangi risiko file palsu yang hanya mengganti ekstensi.

Contoh service sederhana:

<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;

class SecureUploadService
{
    public function assertAllowedFile(UploadedFile $file): void
    {
        $allowedMimeTypes = [
            'application/pdf',
            'image/jpeg',
            'image/png',
        ];

        $detectedMime = $file->getMimeType();

        if (! in_array($detectedMime, $allowedMimeTypes, true)) {
            throw ValidationException::withMessages([
                'document' => 'MIME type file tidak valid atau tidak diizinkan.',
            ]);
        }
    }
}

Mengapa perlu dua lapisan? Karena ekstensi membantu filter cepat, sedangkan MIME yang terdeteksi server memberi sinyal tambahan tentang isi file. Tetap ingat bahwa ini bukan antivirus. Beberapa format kompleks tetap bisa menyimpan payload berbahaya walau MIME tampak benar.

Batasi ukuran file secara berlapis

Batas ukuran di Laravel dengan rule max penting, tetapi tidak cukup. Anda juga perlu memastikan batas di lingkungan runtime tidak lebih longgar dari kebijakan aplikasi.

  • Laravel validation: menolak file terlalu besar pada level aplikasi.
  • PHP runtime: membatasi ukuran upload/request di level proses PHP.
  • Web server/reverse proxy: mencegah request terlalu besar masuk terlalu jauh ke aplikasi.

Jika pengguna melaporkan file selalu gagal sebelum menyentuh controller, periksa konfigurasi PHP dan web server, bukan hanya rule validasi.

Jangan pakai nama file asli sebagai nama penyimpanan

Gunakan nama acak atau identifier internal. Nama asli boleh disimpan sebagai metadata untuk ditampilkan ke pengguna, tetapi jangan dijadikan path final di storage.

Keuntungan pendekatan ini:

  • Mencegah tabrakan nama file.
  • Mengurangi risiko path traversal.
  • Menghindari masalah karakter aneh atau nama yang menyesatkan.
  • Membuat penebakan URL file menjadi lebih sulit.

Simpan di disk non-public

Untuk file yang tidak harus diakses publik, simpan di disk private atau storage lokal non-public. Akses file sebaiknya melalui controller atau mekanisme URL bertanda tangan bila benar-benar perlu dibagikan sementara.

Contoh konfigurasi filesystem untuk disk private lokal:

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

Dengan pola ini, file tidak otomatis bisa diakses dari web. Aplikasi dapat melakukan otorisasi dulu sebelum mengirim file ke pengguna.

Implementasi praktis: request, controller, storage, dan download aman

Controller upload

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDocumentRequest;
use App\Services\SecureUploadService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class DocumentController extends Controller
{
    public function store(StoreDocumentRequest $request, SecureUploadService $uploadService)
    {
        $file = $request->file('document');

        $uploadService->assertAllowedFile($file);

        $extension = strtolower($file->guessExtension() ?: 'bin');
        $filename = Str::uuid()->toString().'.'.$extension;
        $path = $file->storeAs('documents/incoming', $filename, 'private');

        Log::info('File uploaded', [
            'user_id' => optional($request->user())->id,
            'ip' => $request->ip(),
            'original_name' => $file->getClientOriginalName(),
            'stored_path' => $path,
            'mime_detected' => $file->getMimeType(),
            'size' => $file->getSize(),
        ]);

        // Simpan metadata ke database sesuai kebutuhan.
        // Jika ada proses scanning, tandai status file sebagai pending.

        return response()->json([
            'message' => 'File berhasil diupload.',
            'path' => $path,
        ], 201);
    }
}

Hal penting pada contoh di atas:

  • Validasi dilakukan melalui Form Request.
  • Ada pemeriksaan tambahan MIME yang terdeteksi server.
  • Nama file diganti dengan UUID, bukan nama asli pengguna.
  • File disimpan di disk private, bukan public.
  • Upload dicatat ke log untuk audit dan debugging.

Download dengan otorisasi

Karena file disimpan di disk private, akses sebaiknya lewat endpoint yang memeriksa hak akses pengguna.

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Storage;

class DocumentDownloadController extends Controller
{
    public function show(string $path)
    {
        // Ganti dengan policy/authorization sesuai model bisnis.
        abort_unless(Storage::disk('private')->exists($path), 404);

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

Pada aplikasi nyata, jangan menerima path mentah dari user tanpa pengikatan ke record database dan pengecekan kepemilikan. Idealnya, gunakan ID dokumen lalu ambil path dari database setelah otorisasi berhasil.

Signed URL bila perlu akses sementara

Jika file perlu dibagikan sementara, gunakan URL bertanda tangan atau mekanisme akses sementara dari storage backend yang mendukungnya. Ini lebih aman daripada membuat file selalu public.

Trade-off-nya:

  • Kelebihan: akses terbatas waktu, cocok untuk download sementara.
  • Kekurangan: lebih kompleks, perlu manajemen expiry, dan tetap harus mempertimbangkan siapa yang menerima link.

Rate limit, logging, dan pencegahan abuse

Rate limit per user atau IP

Upload file adalah endpoint mahal: memakai bandwidth, storage, dan sering kali memicu proses tambahan. Karena itu, beri pembatasan request.

Contoh konsep rate limiting pada route upload:

use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;

RateLimiter::for('uploads', function (Request $request) {
    $key = $request->user()?->id ? 'user:'.$request->user()->id : 'ip:'.$request->ip();

    return [
        Limit::perMinute(10)->by($key),
    ];
});

Lalu terapkan pada route yang relevan. Untuk endpoint sensitif, Anda bisa menggabungkan pembatasan per menit dan per hari. Jika aplikasi berada di balik proxy, pastikan konfigurasi IP tepercaya benar agar identifikasi per-IP tidak keliru.

Logging yang berguna untuk audit

Minimal log hal berikut:

  • User ID atau identitas anonim bila belum login.
  • IP address dan user agent bila relevan.
  • Nama asli file, path penyimpanan internal, ukuran, MIME terdeteksi.
  • Status validasi atau alasan penolakan.
  • Hasil scanning bila ada.

Hindari mencatat data sensitif secara berlebihan. Log harus membantu investigasi, bukan menjadi sumber kebocoran baru.

Quota dan lifecycle management

Selain rate limit, pertimbangkan quota total per user/tenant dan kebijakan penghapusan file yang tidak lagi dipakai. Banyak abuse terjadi bukan karena request per menit tinggi, tetapi karena akumulasi file yang dibiarkan terus menumpuk.

Scanning malware secara asinkron

Jika risiko file berbahaya relevan untuk aplikasi Anda, scanning malware sebaiknya dilakukan secara asinkron. Jangan memaksa request upload menunggu scan yang lama, karena akan memperburuk latensi dan meningkatkan risiko timeout.

Pola yang umum:

  1. File lolos validasi dasar dan disimpan ke area incoming.
  2. Status file di database di-set ke pending_scan.
  3. Job queue menjalankan scanning melalui service internal atau integrasi scanner eksternal.
  4. Jika aman, status menjadi ready; jika bermasalah, file dikarantina atau dihapus.

Contoh dispatch job setelah upload:

// Setelah file disimpan
ScanUploadedDocument::dispatch($documentId);

Poin pentingnya bukan tool tertentu, tetapi arsitekturnya: pisahkan upload dari scanning. Dengan begitu, aplikasi tetap responsif dan proses keamanan bisa diskalakan terpisah.

Scanning malware bukan pengganti validasi tipe file, pembatasan ukuran, atau kontrol akses. Ini lapisan tambahan dalam defense in depth.

Kesalahan umum yang sering terjadi

  • Hanya mengecek ekstensi tanpa memeriksa tipe file yang terdeteksi server.
  • Menyimpan file langsung di public padahal file seharusnya privat.
  • Menggunakan nama file asli sebagai nama final di storage.
  • Tidak membatasi ukuran di level aplikasi dan infrastruktur.
  • Tidak ada rate limit untuk endpoint upload.
  • Tidak mencatat kejadian penting, sehingga sulit menganalisis abuse.
  • Menganggap file valid berarti aman, padahal malware dan dokumen berbahaya masih mungkin lolos.

Checklist pengujian upload file aman

Gunakan checklist berikut saat menguji implementasi validasi upload file aman di Laravel:

  1. Upload file dengan ekstensi yang diizinkan tetapi isi tidak sesuai.
  2. Upload file dengan ekstensi ganda seperti file.pdf.php.
  3. Upload file tepat di atas batas ukuran maksimum.
  4. Upload file dengan nama asli berisi karakter aneh, spasi, atau pola path traversal.
  5. Pastikan file tidak bisa diakses langsung lewat URL publik bila seharusnya private.
  6. Pastikan endpoint download memeriksa otorisasi, bukan hanya keberadaan file.
  7. Uji rate limit dari user yang sama dan dari IP anonim.
  8. Pastikan log mencatat upload sukses dan upload yang ditolak.
  9. Jika ada scanning async, verifikasi status pending, ready, dan rejected.
  10. Uji file rusak atau korup untuk melihat bagaimana aplikasi merespons.

Penutup

Mengamankan upload file di Laravel bukan soal satu rule validasi. Pendekatan yang lebih realistis adalah menggabungkan validasi server-side, pemeriksaan MIME/content, pembatasan ukuran, nama file acak, penyimpanan di disk non-public, akses terkontrol saat download, rate limit, logging, dan scanning asinkron bila dibutuhkan.

Trade-off utamanya jelas: semakin ketat hardening, semakin besar kompleksitas operasional dan biaya performa. Namun untuk endpoint upload, trade-off ini biasanya layak karena permukaan serangannya memang luas. Mulailah dari kontrol dasar yang benar, lalu tambahkan lapisan sesuai tingkat risiko aplikasi Anda.