Validasi file upload di Laravel tidak cukup hanya dengan aturan required|file|mimes:jpg,png,pdf. Penyerang bisa memalsukan header MIME, memakai ekstensi ganda seperti invoice.pdf.php, mengunggah file terlalu besar, atau membanjiri endpoint upload dengan banyak request. Jika file disimpan sembarangan, risiko lanjutannya adalah remote code execution, kebocoran data, dan penyalahgunaan storage.

Solusi yang aman adalah memakai validasi berlapis: pastikan pengguna berhak mengunggah, validasi file di level request, cocokkan tipe file yang diizinkan, batasi ukuran, ganti nama file, simpan di disk privat, berikan akses baca melalui controller atau signed URL, dan batasi laju request ke endpoint upload. Untuk file berisiko tinggi, tambahkan proses scan sebagai lapisan tambahan, bukan pengganti validasi dasar.

Ancaman umum pada fitur upload file

1. Spoofed MIME type

Klien dapat mengirim Content-Type yang menyesatkan. Misalnya file berbahaya dikirim dengan header image/jpeg. Karena itu, jangan pernah mempercayai metadata dari browser saja. Validasi harus memeriksa isi file secara server-side sejauh memungkinkan.

2. Ekstensi ganda

Nama seperti photo.jpg.php atau report.pdf.exe sering dipakai untuk menyamarkan file executable. Jika aplikasi atau web server salah konfigurasi, file itu bisa diperlakukan sebagai skrip, bukan dokumen.

3. File terlalu besar

Upload file besar bisa menghabiskan bandwidth, memori, CPU, dan storage. Ini bukan hanya masalah UX, tetapi juga peluang denial of service jika endpoint upload tidak memiliki batas ukuran yang jelas.

4. Nama file berbahaya

Nama file dari pengguna bisa berisi karakter aneh, spasi berlebih, unicode ambigu, atau pola traversal seperti ../../evil.php. Jika nama asli dipakai langsung sebagai path penyimpanan, ini membuka risiko overwrite file, traversal, atau bug akses file.

5. Path traversal

Jika path dibentuk dari input pengguna tanpa normalisasi ketat, penyerang bisa mencoba keluar dari direktori yang diharapkan. Karena itu, path penyimpanan harus ditentukan aplikasi, bukan dikontrol klien.

6. Executable upload

Bahaya terbesar muncul saat file yang bisa dieksekusi diunggah ke direktori yang dapat diakses publik. Contohnya skrip PHP, shell, atau file lain yang dieksekusi oleh environment tertentu. Bahkan jika aplikasi Anda hanya menerima gambar, jangan berasumsi semua file di direktori upload aman.

7. Abuse melalui banyak request

Endpoint upload adalah target yang mahal dari sisi resource. Tanpa rate limiting dan otorisasi, satu aktor bisa mengunggah file berkali-kali untuk memenuhi storage atau membebani server.

Prinsip arsitektur upload yang aman

Alur yang disarankan untuk aplikasi web maupun API Laravel adalah sebagai berikut:

  1. Autentikasi dan otorisasi: hanya user tertentu yang boleh mengunggah, dan hanya ke resource yang benar.
  2. Validasi request: cek keberadaan file, ukuran, tipe yang diizinkan, dan aturan bisnis lain.
  3. Pemeriksaan tipe file: jangan hanya percaya nama file atau header dari klien.
  4. Rename file: jangan simpan memakai nama asli dari pengguna sebagai nama final.
  5. Simpan di disk privat: hindari menaruh file mentah langsung di public web root.
  6. Batasi akses baca: layani file lewat controller, policy, atau signed URL.
  7. Rate limit endpoint: batasi jumlah request upload per user atau per IP.
  8. Scan atau proses async: untuk file tertentu, tandai sebagai pending lalu scan sebelum dipublikasikan.

Implementasi validasi file upload di Laravel

1. Lindungi endpoint dengan auth dan otorisasi

Validasi file bukan lapisan pertama. Endpoint upload sebaiknya berada di balik middleware autentikasi, lalu dicek lagi dengan policy atau gate agar user hanya bisa mengunggah ke entitas yang memang dia miliki atau kelola.

use App\Http\Controllers\UploadController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'throttle:uploads'])->group(function () {
    Route::post('/projects/{project}/files', [UploadController::class, 'store']);
});

Di controller:

public function store(UploadFileRequest $request, Project $project)
{
    $this->authorize('uploadFile', $project);

    // lanjut validasi dan penyimpanan
}

Kenapa penting? Jika upload dibuka untuk user anonim atau tanpa policy, validasi file yang bagus pun tidak mencegah abuse atau akses lintas tenant.

2. Gunakan Form Request untuk aturan validator

Pisahkan validasi ke Form Request agar rapi dan mudah diuji.

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;

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

    public function rules(): array
    {
        return [
            'file' => [
                'required',
                'file',
                File::types(['pdf', 'jpg', 'jpeg', 'png'])->max(10 * 1024),
            ],
        ];
    }
}

Contoh di atas membatasi file ke PDF dan gambar umum, dengan ukuran maksimum 10 MB. Saat menulis aturan ukuran, pastikan satuan yang Anda pakai konsisten dengan validator Laravel yang digunakan di proyek Anda.

Catatan: Validasi dengan daftar ekstensi yang diizinkan berguna, tetapi belum cukup. Anda masih perlu memperlakukan hasilnya sebagai filter awal, bukan bukti absolut bahwa file aman.

3. Cek MIME dan ekstensi secara defensif

Laravel menyediakan validasi praktis, tetapi dalam konteks keamanan upload, ada baiknya Anda melakukan pemeriksaan tambahan pada objek file yang diterima. Tujuannya bukan membuat parser antivirus sendiri, melainkan menutup celah yang sering muncul jika aplikasi hanya memeriksa nama file.

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

private function assertAllowedFile(UploadedFile $file): void
{
    $allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png'];
    $allowedMimeTypes = [
        'application/pdf',
        'image/jpeg',
        'image/png',
    ];

    $extension = strtolower($file->getClientOriginalExtension());
    $mimeType = $file->getMimeType();

    if (! in_array($extension, $allowedExtensions, true)) {
        throw ValidationException::withMessages([
            'file' => 'Ekstensi file tidak diizinkan.',
        ]);
    }

    if (! in_array($mimeType, $allowedMimeTypes, true)) {
        throw ValidationException::withMessages([
            'file' => 'Tipe MIME file tidak diizinkan.',
        ]);
    }
}

Kenapa perlu dua lapis?

  • Ekstensi membantu membatasi format yang diharapkan dan mencegah file dengan nama mencurigakan.
  • MIME server-side membantu mendeteksi saat isi file tidak cocok dengan nama yang diklaim.

Namun, pemeriksaan MIME bukan jaminan mutlak terhadap semua file berbahaya. Beberapa file poliglot atau file yang sengaja dibentuk aneh bisa lolos. Karena itu, lapisan berikutnya tetap penting: lokasi penyimpanan, kontrol akses, dan scan.

4. Tolak nama file asli sebagai nama penyimpanan

Nama file dari pengguna sebaiknya hanya disimpan sebagai metadata untuk ditampilkan di UI, bukan dipakai sebagai nama file final di storage. Gunakan nama acak atau ID yang tidak bisa ditebak.

use Illuminate\Support\Str;

$uploadedFile = $request->file('file');
$this->assertAllowedFile($uploadedFile);

$extension = strtolower($uploadedFile->extension() ?: $uploadedFile->getClientOriginalExtension());
$filename = (string) Str::uuid() . '.' . $extension;
$path = "uploads/projects/{$project->id}/{$filename}";

Manfaatnya:

  • Mencegah overwrite file akibat nama sama.
  • Menghindari karakter berbahaya dalam path.
  • Mengurangi peluang enumerasi file dari pola nama yang mudah ditebak.

Jika Anda ingin menampilkan nama asli ke pengguna, simpan ke database sebagai kolom terpisah seperti original_name, setelah dibatasi panjangnya dan dinormalisasi seperlunya.

5. Simpan file di disk privat, bukan public web root

Untuk file yang diunggah pengguna, pilihan paling aman biasanya adalah menyimpan ke disk privat. Jangan langsung meletakkan file mentah ke folder yang dapat diakses publik oleh web server, apalagi jika ada kemungkinan file berbahaya lolos dari validasi.

$storedPath = $uploadedFile->storeAs(
    "uploads/projects/{$project->id}",
    $filename,
    'private'
);

Contoh konfigurasi disk privat:

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

Kenapa ini penting? Jika file tidak berada di web root, file tidak bisa diakses langsung hanya dengan menebak URL. Akses harus melewati aplikasi Anda, sehingga policy, logging, dan pengecekan tambahan tetap berlaku.

Melayani file dengan aman

1. Akses melalui controller

Untuk file privat, tampilkan atau unduhkan file melalui controller yang memeriksa hak akses terlebih dahulu.

use Illuminate\Support\Facades\Storage;

public function show(Project $project, ProjectFile $file)
{
    $this->authorize('viewFile', [$project, $file]);

    abort_unless($file->project_id === $project->id, 404);
    abort_unless(Storage::disk('private')->exists($file->path), 404);

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

Pola ini memberi kontrol penuh: Anda bisa mencatat siapa yang mengunduh, membatasi role tertentu, atau menolak file yang status scannya belum aman.

2. Signed URL untuk akses sementara

Jika Anda perlu memberikan akses terbatas waktu tanpa membuka file secara publik permanen, gunakan signed URL atau mekanisme URL sementara yang sesuai dengan storage backend Anda. Prinsipnya, file hanya dapat diakses dalam durasi tertentu dan tetap terikat ke signature yang valid.

Pendekatan ini cocok untuk:

  • tautan unduhan dari email,
  • akses sementara untuk klien eksternal,
  • integrasi front-end yang butuh URL langsung dalam waktu singkat.

Pastikan tetap ada pengecekan bahwa file memang boleh dibagikan dan statusnya sudah lolos proses keamanan yang diwajibkan.

Tambahkan scan aman sebagai lapisan tambahan

Untuk aplikasi yang menerima file dari pihak luar, terutama PDF, dokumen kantor, arsip, atau lampiran umum, lakukan scan sebagai langkah tambahan. Scan tidak menggantikan validator; ia hanya menambah perlindungan terhadap file berbahaya yang lolos dari filter format dasar.

Alur yang disarankan

  1. File diunggah dan lolos validasi awal.
  2. File disimpan ke lokasi privat dengan status pending_scan.
  3. Aplikasi mengirim job ke queue untuk memindai file.
  4. Jika hasil scan aman, status berubah menjadi ready.
  5. Jika mencurigakan, file dikarantina atau dihapus, lalu akses ditolak.
$projectFile = $project->files()->create([
    'original_name' => $uploadedFile->getClientOriginalName(),
    'stored_name' => $filename,
    'path' => $storedPath,
    'mime_type' => $uploadedFile->getMimeType(),
    'size' => $uploadedFile->getSize(),
    'scan_status' => 'pending',
]);

ScanUploadedFile::dispatch($projectFile->id);

Di job queue, integrasikan scanner yang tersedia di infrastruktur Anda. Jangan menjanjikan keamanan absolut dari scanner; anggap hasil scan sebagai sinyal tambahan. File yang belum selesai dipindai sebaiknya belum bisa diunduh oleh pengguna biasa.

Kapan scan wajib?

  • Saat menerima file dari publik atau pelanggan eksternal.
  • Saat menerima format kompleks seperti PDF, DOCX, XLSX, atau arsip.
  • Saat file nantinya dibagikan lagi ke pengguna lain.

Jika aplikasi hanya menerima gambar profil yang sangat dibatasi format dan ukuran, scan tetap bagus tetapi urgensinya bisa berbeda dibanding portal upload dokumen umum.

Rate limiting untuk endpoint upload

Karena upload mahal dari sisi resource, endpoint ini perlu pembatasan request. Laravel mendukung rate limiting yang bisa dipisahkan khusus untuk upload.

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

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

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

Trade-off: limit yang terlalu ketat bisa mengganggu user sah, terutama saat UI melakukan retry otomatis. Karena itu, sesuaikan dengan jenis file, ukuran rata-rata, dan profil trafik aplikasi Anda. Untuk API publik, pertimbangkan kombinasi per-user, per-IP, dan kuota harian.

Struktur penyimpanan yang aman

Jangan jadikan input pengguna sebagai direktori utama. Tentukan struktur path dari sisi server.

storage/app/private/
└── uploads/
    └── projects/
        └── {project_id}/
            └── {uuid}.pdf

Prinsip yang baik:

  • Path dibentuk aplikasi, bukan dari nama file klien.
  • Nama file acak untuk penyimpanan.
  • Metadata disimpan di database: nama asli, ukuran, MIME terdeteksi, checksum bila perlu, pemilik, dan status scan.
  • File privat dipisah dari aset publik aplikasi.

Jika Anda menyimpan file di object storage, prinsipnya tetap sama: bucket atau prefix untuk file privat harus tidak terbuka publik secara default.

Contoh implementasi controller yang lebih lengkap

namespace App\Http\Controllers;

use App\Http\Requests\UploadFileRequest;
use App\Jobs\ScanUploadedFile;
use App\Models\Project;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class UploadController extends Controller
{
    public function store(UploadFileRequest $request, Project $project)
    {
        $this->authorize('uploadFile', $project);

        /** @var UploadedFile $file */
        $file = $request->file('file');
        $this->assertAllowedFile($file);

        $extension = strtolower($file->extension() ?: $file->getClientOriginalExtension());
        $filename = (string) Str::uuid() . '.' . $extension;
        $directory = "uploads/projects/{$project->id}";

        $path = $file->storeAs($directory, $filename, 'private');

        $record = $project->files()->create([
            'original_name' => mb_substr($file->getClientOriginalName(), 0, 255),
            'stored_name' => $filename,
            'path' => $path,
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
            'scan_status' => 'pending',
        ]);

        ScanUploadedFile::dispatch($record->id);

        return response()->json([
            'id' => $record->id,
            'name' => $record->original_name,
            'status' => $record->scan_status,
        ], 201);
    }

    private function assertAllowedFile(UploadedFile $file): void
    {
        $allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png'];
        $allowedMimeTypes = ['application/pdf', 'image/jpeg', 'image/png'];

        $ext = strtolower($file->getClientOriginalExtension());
        $mime = $file->getMimeType();

        if (! in_array($ext, $allowedExtensions, true)) {
            throw ValidationException::withMessages([
                'file' => 'Ekstensi file tidak diizinkan.',
            ]);
        }

        if (! in_array($mime, $allowedMimeTypes, true)) {
            throw ValidationException::withMessages([
                'file' => 'Tipe file tidak diizinkan.',
            ]);
        }
    }
}

Contoh ini belum mencakup semua kebutuhan produksi, tetapi sudah menunjukkan pola penting: auth, policy, validator, pemeriksaan tipe file, rename, penyimpanan privat, metadata database, dan scan async.

Kesalahan umum yang harus dihindari

  • Mengandalkan ekstensi saja. File bisa dinamai ulang dengan mudah.
  • Mengandalkan MIME dari klien saja. Header request mudah dipalsukan.
  • Menyimpan file upload ke folder publik tanpa alasan kuat dan tanpa pembatasan tipe.
  • Memakai nama file asli sebagai nama final. Ini rawan konflik, traversal, dan karakter aneh.
  • Tidak membatasi ukuran file di validator maupun infrastruktur.
  • Tidak memasang rate limit pada endpoint upload.
  • Mengizinkan tipe file terlalu luas seperti menerima semua dokumen dan arsip tanpa kebutuhan jelas.
  • Tidak memeriksa otorisasi terhadap resource, misalnya user bisa mengunggah ke project milik user lain.
  • Langsung menyediakan file untuk diunduh setelah upload padahal status scan masih pending.
  • Tidak mencatat metadata penting seperti pemilik file, ukuran, MIME terdeteksi, dan waktu unggah.

Tips debugging saat validasi upload gagal

1. Bedakan masalah validator dan masalah server

Jika file selalu gagal di ukuran tertentu, penyebabnya bisa berasal dari validator Laravel, web server, atau konfigurasi runtime PHP. Pastikan batas di semua lapisan konsisten. Jika tidak, request bisa ditolak sebelum masuk ke validator aplikasi.

2. Log MIME yang terdeteksi server

Jika file sah pengguna ditolak, log nilai MIME yang dibaca server dan ekstensi file yang diterima. Ini membantu melihat apakah ada perbedaan antara file nyata dan asumsi aturan Anda.

3. Uji dengan file yang sengaja dimanipulasi

Buat sampel seperti:

  • file gambar yang diganti ekstensi menjadi .php,
  • nama file dengan ekstensi ganda,
  • file berukuran sedikit di atas batas,
  • nama file dengan karakter tidak biasa.

Tujuannya untuk memastikan endpoint Anda gagal dengan aman dan pesan error tetap jelas.

4. Pastikan disk privat benar-benar privat

Jika file tetap bisa diakses langsung via URL publik, ada kemungkinan Anda salah memilih disk atau ada konfigurasi web server/storage yang membuat direktori privat ikut terekspos.

Checklist hardening upload di Laravel

  • Endpoint upload dilindungi auth.
  • Ada policy/gate untuk menentukan siapa yang boleh upload ke resource tertentu.
  • Validator membatasi keberadaan file, tipe, dan ukuran.
  • Terdapat pengecekan ekstensi dan MIME secara defensif.
  • Nama file penyimpanan menggunakan UUID atau nama acak.
  • Nama file asli hanya disimpan sebagai metadata.
  • Path penyimpanan dibentuk aplikasi, bukan dari input pengguna.
  • File disimpan di disk privat, bukan langsung di web root.
  • Akses baca file dilakukan via controller atau signed URL.
  • Endpoint upload memakai rate limiting.
  • File berisiko melewati proses scan async sebelum tersedia untuk diunduh.
  • Status file seperti pending, ready, atau rejected disimpan di database.
  • Logging dan monitoring tersedia untuk upload gagal, scan gagal, dan lonjakan trafik.

Penutup

Validasi file upload di Laravel yang aman selalu bersifat berlapis. Validator hanya menyaring input awal; keamanan sebenarnya datang dari kombinasi otorisasi, pembatasan format dan ukuran, penamaan aman, penyimpanan privat, kontrol akses saat download, rate limiting, dan scan tambahan untuk file berisiko. Jika Anda menerapkan semua lapisan ini, risiko spoofed MIME, ekstensi ganda, executable upload, path traversal, dan abuse endpoint dapat ditekan secara signifikan.

Mulailah dari kebijakan paling ketat: izinkan sedikit tipe file, simpan di lokasi privat, dan jangan pernah percaya nama file dari pengguna. Setelah itu, tambahkan scan dan kontrol akses yang sesuai dengan kebutuhan bisnis aplikasi Anda.