Fitur upload file di Laravel tidak cukup diamankan hanya dengan memeriksa ekstensi seperti .jpg atau .pdf. Risiko umum datang dari spoofed MIME, ekstensi ganda seperti invoice.pdf.php, file executable, nama file berbahaya, ukuran file berlebih, dan file yang tanpa sengaja tersimpan di lokasi yang bisa diakses publik.

Solusi yang lebih aman adalah memakai validasi berlapis: terima hanya tipe file yang diizinkan, batasi ukuran, jangan percaya nama file asli dari pengguna, simpan file di disk non-public, dan layani akses file melalui controller atau signed URL. Dengan pendekatan ini, Anda mengurangi peluang file berbahaya dieksekusi atau diakses tanpa otorisasi.

Ancaman umum pada fitur upload file

Sebelum masuk ke implementasi, pahami ancaman yang memang sering terjadi pada endpoint upload:

  • Spoofed MIME: klien bisa mengirim header MIME palsu agar file berbahaya terlihat seperti gambar atau PDF.
  • Ekstensi ganda: file seperti avatar.jpg.php bisa lolos jika aplikasi hanya memeriksa bagian akhir nama file secara naif.
  • File executable: skrip, binary, atau file yang dapat dieksekusi server tidak boleh disimpan di lokasi yang dapat diakses langsung.
  • Nama file berbahaya: nama file bisa mengandung karakter aneh, path traversal, atau pola yang menyulitkan logging dan audit.
  • Ukuran berlebih: file besar dapat menghabiskan memori, bandwidth, storage, atau waktu proses.
  • Akses publik tidak sengaja: file sensitif tersimpan di direktori publik dan dapat diakses langsung lewat URL statis.

Karena itu, keamanan upload file harus dipandang sebagai kombinasi validasi, penyimpanan, kontrol akses, dan observabilitas.

Prinsip implementasi upload file aman di Laravel

1. Gunakan whitelist, bukan blacklist

Jangan mencoba melarang semua format berbahaya satu per satu. Lebih aman mendefinisikan tipe file yang memang dibutuhkan aplikasi, misalnya hanya jpg, png, dan pdf.

2. Jangan percaya nama file dari pengguna

Nama file asli sebaiknya diperlakukan hanya sebagai metadata opsional. Untuk penyimpanan, gunakan nama acak yang dihasilkan server. Ini mencegah benturan nama, karakter berbahaya, dan pola penamaan yang dapat ditebak.

3. Simpan file di disk non-public

File upload tidak sebaiknya ditaruh di lokasi yang dilayani langsung oleh web server. Simpan di disk privat, lalu berikan akses melalui controller yang memeriksa otorisasi atau menggunakan URL bertanda tangan.

4. Validasi metadata dan konten sedekat mungkin dengan server

Validasi di sisi frontend tetap berguna untuk pengalaman pengguna, tetapi jangan dianggap sebagai kontrol keamanan utama. Keputusan final harus dibuat di sisi backend.

Konfigurasi filesystem: isolasi storage dari akses publik

Mulailah dengan memastikan ada disk privat untuk menyimpan file upload. Prinsipnya, file berada di luar akses publik langsung, dan aplikasi yang memutuskan kapan file boleh diunduh.

// config/filesystems.php

'disks' => [
    // ... disk lain

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

Dengan konfigurasi ini, file tersimpan di dalam storage/app/private_uploads, bukan di direktori publik. Hindari membuat symlink ke lokasi ini jika file tidak memang harus tersedia untuk publik.

Catatan: Jika Anda menggunakan object storage seperti S3, prinsipnya tetap sama: simpan file sebagai private object, lalu sajikan melalui signed URL atau alur backend yang memeriksa izin akses.

Validasi berlapis dengan Form Request

Laravel Form Request cocok untuk memusatkan validasi upload. Di sini kita bisa membatasi ukuran, whitelist ekstensi, dan menambahkan pengecekan konten file yang lebih ketat.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Validator;

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

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

    public function withValidator(Validator $validator): void
    {
        $validator->after(function (Validator $validator) {
            /** @var UploadedFile|null $file */
            $file = $this->file('file');

            if (! $file instanceof UploadedFile) {
                return;
            }

            $originalName = $file->getClientOriginalName();
            $extension = strtolower($file->getClientOriginalExtension());
            $guessedExtension = strtolower($file->guessExtension() ?? '');
            $mimeType = strtolower($file->getMimeType() ?? '');

            if ($this->hasDoubleExtension($originalName)) {
                $validator->errors()->add('file', 'Nama file mengandung ekstensi ganda yang tidak diizinkan.');
            }

            if (! in_array($extension, ['jpg', 'jpeg', 'png', 'pdf'], true)) {
                $validator->errors()->add('file', 'Ekstensi file tidak diizinkan.');
            }

            if ($guessedExtension !== '' && ! in_array($guessedExtension, ['jpg', 'jpeg', 'png', 'pdf'], true)) {
                $validator->errors()->add('file', 'Tipe file terdeteksi tidak sesuai whitelist.');
            }

            if (! in_array($mimeType, ['image/jpeg', 'image/png', 'application/pdf'], true)) {
                $validator->errors()->add('file', 'MIME file tidak diizinkan.');
            }
        });
    }

    private function hasDoubleExtension(string $filename): bool
    {
        $dangerousExtensions = [
            'php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'js', 'pl', 'py'
        ];

        $parts = explode('.', strtolower($filename));

        if (count($parts) <= 2) {
            return false;
        }

        array_shift($parts); // abaikan nama file dasar

        foreach ($parts as $part) {
            if (in_array($part, $dangerousExtensions, true)) {
                return true;
            }
        }

        return false;
    }
}

Mengapa perlu mimes dan mimetypes sekaligus?

Keduanya memeriksa hal yang berbeda:

  • mimes berguna untuk membatasi ekstensi/format yang diharapkan.
  • mimetypes membantu membatasi MIME yang diterima.

Menggabungkan keduanya membuat validasi lebih ketat. Namun, Anda tetap tidak boleh berasumsi bahwa satu pemeriksaan selalu cukup, karena MIME bisa dipalsukan dan file yang tidak standar kadang terdeteksi berbeda antar lingkungan. Karena itu, pola aman adalah whitelist + pemeriksaan MIME + penyimpanan privat.

Batas ukuran file

Rule max:5120 membatasi ukuran file ke 5 MB. Ini penting bukan hanya untuk UX, tetapi juga untuk mencegah konsumsi resource berlebihan. Pastikan batas ini konsisten dengan konfigurasi server atau PHP agar pengguna tidak menerima error yang membingungkan sebelum validasi Laravel berjalan.

Jika upload Anda sering gagal tanpa pesan validasi, periksa konfigurasi tingkat server seperti batas ukuran request atau body upload. Nilainya harus selaras dengan limit di aplikasi.

Controller upload: rename acak, simpan di disk privat, dan catat metadata

Setelah file lolos validasi, simpan dengan nama acak. Jangan gunakan nama asli sebagai path penyimpanan.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDocumentRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class DocumentController extends Controller
{
    public function store(StoreDocumentRequest $request): JsonResponse
    {
        $file = $request->file('file');

        $extension = strtolower($file->guessExtension() ?: $file->getClientOriginalExtension());
        $filename = Str::uuid()->toString() . '.' . $extension;
        $directory = 'documents/' . now()->format('Y/m');

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

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

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

Mengapa nama acak penting?

  • Mencegah overwrite file dengan nama sama.
  • Mengurangi kemungkinan file dapat ditebak melalui URL.
  • Menghindari karakter berbahaya atau nama yang tidak valid di filesystem.
  • Menyederhanakan sanitasi, karena nama file tidak lagi dikendalikan pengguna.

Bagaimana dengan nama file asli?

Jika perlu ditampilkan kembali ke pengguna, simpan nama asli sebagai metadata di database, bukan sebagai nama file fisik. Saat ditampilkan, tetap lakukan escaping di view untuk mencegah masalah output.

Menyajikan file dengan aman: lewat controller atau signed URL

File privat sebaiknya tidak diakses langsung melalui path storage. Gunakan controller untuk memeriksa otorisasi dan mengirim file jika pengguna memang berhak.

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

class PrivateFileController extends Controller
{
    public function show(string $path): StreamedResponse
    {
        // Contoh sederhana. Di aplikasi nyata, cek otorisasi berdasarkan model/data milik pengguna.
        abort_unless(auth()->check(), 403);

        $normalizedPath = ltrim($path, '/');

        abort_if(
            str_contains($normalizedPath, '../') || str_contains($normalizedPath, '..\\'),
            404
        );

        abort_unless(Storage::disk('private_uploads')->exists($normalizedPath), 404);

        return Storage::disk('private_uploads')->response($normalizedPath);
    }
}

Pola ini membantu mencegah akses publik tidak sengaja dan memberi tempat yang tepat untuk menerapkan:

  • otorisasi per pengguna atau per resource,
  • logging akses file,
  • header respons tambahan,
  • audit dan pembatasan akses sementara.

Signed URL kapan dipakai?

Jika Anda perlu memberi akses sementara tanpa memaksa semua traffic file melewati aplikasi, signed URL adalah pilihan yang baik. Pendekatan ini umum dipakai pada object storage privat. Pilih signed URL ketika:

  • file berukuran besar,
  • jumlah download tinggi,
  • Anda butuh akses sementara dengan masa berlaku singkat.

Jika file harus selalu melewati otorisasi dinamis yang kompleks, streaming melalui controller sering lebih mudah dikontrol.

Pencegahan path traversal dan manipulasi path

Jangan pernah menerima path bebas dari pengguna lalu langsung menggabungkannya dengan root storage. Risiko utamanya adalah path traversal, misalnya ../../.env.

Praktik yang lebih aman:

  • Simpan referensi file di database dan cari berdasarkan ID, bukan berdasarkan path mentah dari request.
  • Jika path memang harus digunakan, normalisasi dan tolak pola seperti ../ atau separator yang tidak valid.
  • Jangan izinkan pengguna menentukan direktori target upload secara bebas.

Pendekatan terbaik biasanya: endpoint menerima ID resource, lalu aplikasi mengambil path sebenarnya dari database.

Whitelisting tipe file yang realistis

Whitelist harus ketat dan sesuai kebutuhan bisnis. Jika aplikasi hanya menerima dokumen PDF, jangan izinkan gambar. Jika hanya butuh avatar, jangan izinkan PDF.

Contoh whitelist yang lebih aman:

  • Avatar: image/jpeg, image/png
  • Dokumen: application/pdf
  • Lampiran internal tertentu: hanya format yang benar-benar dibutuhkan

Semakin sedikit format yang didukung, semakin kecil permukaan serangan dan semakin sederhana proses validasinya.

Scanning file sebagai lapisan tambahan

Untuk aplikasi yang menerima file dari pihak eksternal atau memproses lampiran dokumen, pertimbangkan scanning antivirus atau malware sebagai kontrol tambahan. Ini tidak menggantikan validasi Laravel, tetapi menambah lapisan pertahanan.

Pola implementasi yang umum:

  1. Upload file ke storage privat.
  2. Catat status file sebagai pending scan.
  3. Jalankan scanning secara asynchronous melalui queue.
  4. Baru tandai file sebagai dapat diakses setelah lolos scan.

Trade-off-nya adalah kompleksitas bertambah dan akses file bisa tertunda. Untuk sistem internal kecil, ini mungkin berlebihan. Untuk aplikasi yang menerima upload dari internet publik, ini sering layak dipertimbangkan.

Logging dan audit upload

Logging membantu investigasi insiden dan debugging. Minimal catat:

  • ID pengguna atau identitas sistem,
  • alamat IP,
  • nama file asli,
  • path penyimpanan internal,
  • MIME yang terdeteksi,
  • ukuran file,
  • waktu upload,
  • hasil validasi atau alasan penolakan jika perlu.

Hindari menyimpan informasi sensitif yang tidak diperlukan. Tujuan logging adalah audit dan troubleshooting, bukan menyalin seluruh konten file atau data yang tidak relevan.

Rate limit untuk endpoint upload

Endpoint upload lebih mahal dibanding endpoint biasa karena memakai bandwidth, storage, dan I/O. Karena itu, tambahkan rate limit untuk membatasi abuse.

<?php

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

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

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

Lalu terapkan pada route upload:

use App\Http\Controllers\DocumentController;

Route::middleware(['auth', 'throttle:file-uploads'])
    ->post('/documents', [DocumentController::class, 'store']);

Angka limit harus disesuaikan dengan pola penggunaan aplikasi. Jangan menyalin nilai secara buta. Endpoint publik umumnya butuh limit lebih ketat daripada panel internal.

Contoh route yang lebih aman

use App\Http\Controllers\DocumentController;
use App\Http\Controllers\PrivateFileController;

Route::middleware(['auth', 'throttle:file-uploads'])
    ->group(function () {
        Route::post('/documents', [DocumentController::class, 'store']);
        Route::get('/files/{path}', [PrivateFileController::class, 'show'])
            ->where('path', '.*');
    });

Meski route download di atas menerima path, praktik yang lebih baik tetap memakai ID resource dan mengambil path dari database. Contoh di atas hanya menunjukkan cara menambahkan lapisan pemeriksaan dasar.

Testing: pastikan file terlarang benar-benar ditolak

Pengujian sangat penting karena bug pada fitur upload sering baru terlihat setelah ada file tak terduga dari pengguna. Gunakan Storage::fake() untuk memastikan file hanya tersimpan saat validasi lolos.

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class DocumentUploadTest extends TestCase
{
    public function test_rejects_php_file_upload(): void
    {
        Storage::fake('private_uploads');

        $user = User::factory()->create();

        $file = UploadedFile::fake()->create('shell.php', 10, 'application/x-php');

        $response = $this->actingAs($user)
            ->postJson('/documents', ['file' => $file]);

        $response->assertStatus(422);
        Storage::disk('private_uploads')->assertDirectoryEmpty('/');
    }

    public function test_rejects_double_extension_file(): void
    {
        Storage::fake('private_uploads');

        $user = User::factory()->create();

        $file = UploadedFile::fake()->create('invoice.pdf.php', 10, 'application/pdf');

        $response = $this->actingAs($user)
            ->postJson('/documents', ['file' => $file]);

        $response->assertStatus(422);
    }

    public function test_accepts_valid_pdf_file(): void
    {
        Storage::fake('private_uploads');

        $user = User::factory()->create();

        $file = UploadedFile::fake()->create('report.pdf', 100, 'application/pdf');

        $response = $this->actingAs($user)
            ->postJson('/documents', ['file' => $file]);

        $response->assertStatus(201);
    }
}

Skenario test yang sebaiknya ada

  • file executable ditolak,
  • ekstensi ganda ditolak,
  • MIME di luar whitelist ditolak,
  • ukuran melebihi batas ditolak,
  • file valid diterima dan tersimpan di disk privat,
  • pengguna tanpa izin tidak bisa mengakses file privat,
  • rate limit aktif pada upload berulang.

Kesalahan umum yang sering terjadi

  • Hanya memeriksa ekstensi dan menganggap itu cukup.
  • Menyimpan file di direktori publik lalu berharap validasi sudah menutup semua risiko.
  • Menggunakan nama file asli sebagai nama final di storage.
  • Tidak membatasi ukuran file sehingga resource server mudah habis.
  • Tidak menguji kasus file aneh seperti ekstensi ganda atau MIME palsu.
  • Mengizinkan terlalu banyak format tanpa kebutuhan nyata.

Checklist implementasi yang bisa langsung diterapkan

  1. Buat disk storage privat khusus upload.
  2. Gunakan Form Request untuk rule file, max, mimes, dan mimetypes.
  3. Tambahkan validasi lanjutan untuk menolak ekstensi ganda dan tipe berbahaya.
  4. Pakai whitelist tipe file yang benar-benar diperlukan.
  5. Simpan file dengan nama acak atau UUID, bukan nama asli.
  6. Jangan simpan file upload sensitif di disk public.
  7. Sajikan file lewat controller dengan otorisasi, atau signed URL untuk akses sementara.
  8. Cegah path traversal dengan tidak menerima path mentah dari pengguna.
  9. Tambahkan rate limit pada endpoint upload.
  10. Catat log upload dan buat test untuk file terlarang.
  11. Pertimbangkan scanning asynchronous jika profil risiko aplikasi tinggi.

Penutup

Upload file aman dengan validasi MIME dan isolasi storage di Laravel berarti Anda tidak hanya memeriksa ekstensi, tetapi membangun beberapa lapisan kontrol sekaligus. Kombinasi whitelist tipe file, pembatasan ukuran, rename acak, storage privat, dan akses melalui controller atau signed URL adalah fondasi yang praktis dan langsung bisa diterapkan.

Jika Anda sedang memperbaiki fitur upload yang sudah ada, prioritas terbaik biasanya: pindahkan file ke disk non-public, ketatkan whitelist, hilangkan penggunaan nama file asli sebagai path, lalu tambahkan test untuk kasus file terlarang. Langkah-langkah itu memberi peningkatan keamanan yang nyata tanpa harus merombak seluruh arsitektur aplikasi.