Laravel file upload aman tidak cukup dengan memeriksa ekstensi file di browser. Jika endpoint upload menerima file tanpa validasi MIME yang benar, tanpa batas ukuran, dan langsung menyimpannya ke direktori public, aplikasi bisa menjadi pintu masuk untuk executable upload, penyebaran malware, atau sekadar pemborosan resource melalui abuse traffic.
Pendekatan yang aman adalah defense in depth: validasi server-side, cek MIME dan ekstensi, batasi ukuran, gunakan nama file acak, simpan di storage non-public, sajikan file melalui controller atau signed URL, serta tambahkan rate limit, authorization, dan audit log. Di Laravel, semua ini bisa diterapkan dengan pola yang cukup sederhana dan mudah dirawat.
Ancaman umum pada fitur upload file
1. Spoofing MIME dan ekstensi
Penyerang dapat mengubah nama file menjadi foto.jpg padahal isinya bukan gambar. Jika aplikasi hanya memeriksa ekstensi, file berbahaya bisa lolos. Karena itu, validasi harus dilakukan di server dan tidak hanya mengandalkan getClientOriginalExtension() atau metadata dari browser.
2. File terlalu besar
Upload file besar bisa menghabiskan bandwidth, storage, memory, dan waktu proses. Bahkan file yang valid pun dapat menjadi vektor denial of service jika tidak ada batas ukuran yang jelas di level aplikasi dan web server.
3. Path traversal
Jika nama file atau path disusun dari input pengguna tanpa sanitasi, penyerang bisa mencoba pola seperti ../../ untuk menimpa atau membaca file lain. Dalam Laravel, risiko ini biasanya muncul saat developer menyimpan file dengan path buatan sendiri dari request.
4. Executable upload
Masalah paling berbahaya terjadi ketika file yang diunggah dapat dieksekusi oleh web server, misalnya skrip PHP di direktori yang dapat diakses publik. Ini bisa berujung pada remote code execution jika konfigurasi server longgar.
5. Malware relay
Aplikasi Anda bisa menjadi tempat transit file berbahaya. Walaupun file tidak dieksekusi di server, tetap ada risiko reputasi, penyalahgunaan sistem, dan dampak ke pengguna lain jika file itu dibagikan kembali tanpa kontrol.
6. Abuse traffic
Endpoint upload adalah target empuk untuk spam, brute force akun, dan flood request. Tanpa autentikasi, rate limit, dan logging, sulit mendeteksi serta menahan penyalahgunaan.
Prinsip dasar hardening upload di Laravel
- Selalu validasi di server-side, bukan hanya di frontend.
- Jangan percaya nama file asli dari client.
- Simpan file di storage non-public bila file tidak harus diakses langsung.
- Gunakan nama file acak untuk mencegah tebakan, benturan nama, dan injeksi path.
- Batasi ukuran dan tipe file sesuai kebutuhan bisnis, bukan daftar yang terlalu longgar.
- Lindungi endpoint dengan auth, authorization, dan rate limit.
- Catat aktivitas penting seperti upload, replace, dan delete.
- Jangan izinkan eksekusi skrip di direktori upload.
Validasi server-side dengan Form Request
Form Request membuat aturan validasi lebih rapi dan konsisten. Fokus utamanya adalah memastikan request memang mengandung file, ukurannya dibatasi, dan tipenya sesuai kebutuhan.
Contoh Form Request upload dokumen
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDocumentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'document' => [
'required',
'file',
'max:5120', // 5 MB dalam KB
'mimes:pdf,jpg,jpeg,png',
'mimetypes:application/pdf,image/jpeg,image/png',
],
];
}
public function messages(): array
{
return [
'document.max' => 'Ukuran file melebihi batas 5 MB.',
'document.mimes' => 'Ekstensi file tidak diizinkan.',
'document.mimetypes' => 'Tipe file tidak valid.',
];
}
}Kombinasi mimes dan mimetypes membantu memperketat validasi. mimes berguna untuk membatasi jenis file yang diizinkan, sedangkan mimetypes menambah lapisan pengecekan berdasarkan tipe konten yang terdeteksi di server.
Catatan: Validasi MIME tetap bukan antivirus. File yang lolos validasi belum tentu aman dari malware. Jika risiko bisnis tinggi, tambahkan proses scanning terpisah sebelum file tersedia untuk diunduh atau diproses lebih lanjut.
Mengapa tidak cukup memeriksa ekstensi?
Ekstensi adalah string pada nama file dan mudah dimanipulasi. File shell.php bisa diganti nama menjadi invoice.pdf. Jika aplikasi menerima file berdasarkan nama asli saja, validasi bisa ditembus. Laravel akan membantu mendeteksi tipe file melalui file yang benar-benar diunggah, tetapi developer tetap harus membatasi jenis file dengan tegas.
Validasi tambahan di controller bila perlu
Untuk kasus sensitif, Anda bisa menambahkan verifikasi lanjutan setelah validasi dasar, misalnya membandingkan MIME terdeteksi dan ekstensi hasil tebakan server. Intinya, jangan menggunakan nilai yang dikirim client sebagai sumber kebenaran utama.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreDocumentRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class DocumentController extends Controller
{
public function store(StoreDocumentRequest $request)
{
$file = $request->file('document');
$detectedMime = $file->getMimeType();
$guessedExtension = $file->extension();
$allowed = [
'application/pdf' => ['pdf'],
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
];
if (! isset($allowed[$detectedMime]) || ! in_array($guessedExtension, $allowed[$detectedMime], true)) {
throw new HttpResponseException(
response()->json(['message' => 'File gagal diverifikasi.'], 422)
);
}
// lanjut ke proses simpan
}
}Pola ini tidak sempurna untuk semua format file, tetapi berguna untuk memperkecil peluang file yang disamarkan lolos ke storage permanen.
Penyimpanan aman: nama acak, non-public, dan path yang terkendali
Gunakan disk storage, bukan path manual dari user
Hindari kode yang menyusun path dari input pengguna seperti nama folder dari request tanpa whitelist yang jelas. Lebih aman jika path ditentukan aplikasi, misalnya berdasarkan entitas internal seperti ID user atau tipe dokumen.
<?php
$path = $request->file('document')->store('documents/tmp', 'local');Metode store() akan membuat nama file acak sehingga mengurangi benturan nama dan mencegah pemanfaatan nama file asli untuk serangan path traversal. Jika perlu struktur yang lebih rapi, tentukan folder tetap dari aplikasi, bukan dari input bebas pengguna.
Simpan di storage non-public
Untuk file sensitif atau file yang tidak perlu diakses langsung, gunakan disk local atau disk privat lain. Dengan begitu, file tidak tersedia langsung lewat URL publik dan harus melalui kontrol aplikasi.
<?php
$path = $request->file('document')->store('documents', 'local');Keuntungan pendekatan ini:
- Aplikasi bisa memeriksa authorization sebelum file dikirim.
- Lebih mudah menambahkan audit log unduhan.
- File tidak bisa ditebak hanya dari nama URL.
- Risiko eksekusi langsung oleh web server jauh lebih kecil.
Jangan pakai nama file asli sebagai nama simpan
Nama file asli sering mengandung karakter aneh, spasi, pola traversal, atau informasi sensitif. Jika Anda ingin menyimpan nama asli untuk keperluan tampilan, simpan sebagai metadata di database, bukan sebagai nama fisik file di storage.
<?php
$originalName = $request->file('document')->getClientOriginalName();
$path = $request->file('document')->store('documents', 'local');
// Simpan $originalName di database untuk ditampilkan ke userMenyajikan file dengan aman: controller atau signed URL
Serving via controller
Jika file privat, route download sebaiknya melalui controller. Di sini Anda bisa memeriksa apakah user berhak mengakses file tersebut sebelum mengirim respons file.
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
class DocumentDownloadController extends Controller
{
public function show(Document $document)
{
$this->authorize('view', $document);
abort_unless(Storage::disk('local')->exists($document->path), 404);
return Storage::disk('local')->download(
$document->path,
$document->original_name
);
}
}Keuntungan pola ini adalah kontrol akses penuh. Anda juga bisa menambahkan header respons, log unduhan, atau pemblokiran jika file ditandai berisiko.
Kapan signed URL berguna?
Jika file berada di object storage yang mendukung URL bertanda tangan, signed URL cocok untuk akses sementara tanpa harus melewatkan semua traffic lewat aplikasi. Pendekatan ini baik untuk file besar atau beban unduh tinggi. Namun, tetap pastikan URL memiliki masa berlaku singkat dan hanya dibuat setelah authorization berhasil.
Larangan execute di direktori upload
Meskipun file sudah disimpan di luar public web root, tetap pastikan kebijakan server tidak mengizinkan eksekusi skrip di direktori upload. Ini penting terutama jika ada kebutuhan menyimpan file di lokasi yang berdekatan dengan aset web.
Prinsipnya sederhana:
- Jangan tempatkan upload di direktori yang mengeksekusi PHP atau skrip lain.
- Jangan campur file upload dengan source code aplikasi.
- Gunakan konfigurasi web server untuk mematikan script execution pada folder upload publik bila memang harus ada.
Detail konfigurasinya bergantung pada web server yang dipakai, jadi implementasinya harus disesuaikan. Yang penting, jangan berasumsi bahwa validasi file saja sudah cukup.
Rate limit, auth, dan authorization untuk abuse guard
Lindungi route upload dengan middleware
Endpoint upload hampir selalu sebaiknya berada di balik autentikasi. Selain itu, tambahkan rate limit agar user atau IP tidak bisa mengirim request upload tanpa batas.
<?php
use App\Http\Controllers\DocumentController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'throttle:uploads'])
->post('/documents', [DocumentController::class, 'store'])
->name('documents.store');Anda bisa mendefinisikan limiter khusus berdasarkan user ID atau IP. Ini membantu menahan spam dan flood request.
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('uploads', function (Request $request) {
$key = $request->user()?->id ?: $request->ip();
return [
Limit::perMinute(10)->by($key),
];
});
}
}Angka limit harus disesuaikan dengan kebutuhan bisnis. Jangan menyalin angka mentah tanpa melihat pola penggunaan nyata.
Authorization per entitas
Jangan hanya memeriksa apakah user sudah login. Pastikan juga user memang boleh mengunggah file ke resource tertentu, misalnya profilnya sendiri, tiket yang ia miliki, atau proyek yang menjadi tanggung jawabnya.
<?php
public function store(StoreDocumentRequest $request)
{
$this->authorize('create', Document::class);
// proses upload
}Untuk kasus yang melibatkan entitas tertentu, gunakan policy pada model terkait agar aturan akses terpusat dan mudah direview.
Audit log dan observability
Minimal, catat peristiwa berikut:
- Siapa yang mengunggah file.
- Kapan upload terjadi.
- Nama asli file, MIME terdeteksi, ukuran, dan path storage.
- Status validasi atau penolakan.
- Perubahan file, penggantian file lama, dan penghapusan.
- Unduhan file sensitif, bila relevan.
Audit log berguna untuk investigasi insiden, debugging, dan mendeteksi pola abuse. Jangan log konten file, token sensitif, atau data yang tidak perlu.
<?php
use Illuminate\Support\Facades\Log;
Log::info('document_uploaded', [
'user_id' => $request->user()->id,
'original_name' => $originalName,
'stored_path' => $path,
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
]);Contoh implementasi end-to-end upload aman
Berikut contoh alur praktis: validasi request, simpan file ke disk privat, catat metadata ke database, lalu kembalikan respons yang aman.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreDocumentRequest;
use App\Models\Document;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DocumentController extends Controller
{
public function store(StoreDocumentRequest $request)
{
$this->authorize('create', Document::class);
$file = $request->file('document');
$originalName = $file->getClientOriginalName();
$mime = $file->getMimeType();
$size = $file->getSize();
$allowed = [
'application/pdf' => ['pdf'],
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
];
$extension = $file->extension();
abort_unless(isset($allowed[$mime]) && in_array($extension, $allowed[$mime], true), 422, 'File gagal diverifikasi.');
$document = DB::transaction(function () use ($request, $file, $originalName, $mime, $size) {
$path = $file->store('documents', 'local');
return Document::create([
'user_id' => $request->user()->id,
'original_name' => $originalName,
'path' => $path,
'mime' => $mime,
'size' => $size,
]);
});
Log::info('document_uploaded', [
'document_id' => $document->id,
'user_id' => $request->user()->id,
'path' => $document->path,
'mime' => $document->mime,
'size' => $document->size,
]);
return response()->json([
'id' => $document->id,
'name' => $document->original_name,
], 201);
}
}Perhatikan bahwa metadata disimpan terpisah dari file fisik. Ini memudahkan kontrol akses, pencarian, dan rotasi file di kemudian hari.
Flow aman untuk mengganti dan menghapus file lama
Mengganti file sering menimbulkan bug: file baru sukses diupload tetapi database gagal diperbarui, atau file lama terhapus terlalu cepat sehingga data menjadi rusak jika proses berikutnya gagal.
Pola yang lebih aman
- Upload file baru terlebih dahulu.
- Simpan path baru di database dalam transaksi jika relevan.
- Setelah data baru benar-benar aktif, baru hapus file lama.
- Jika penghapusan gagal, log insiden dan jadwalkan cleanup ulang.
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
public function replace(StoreDocumentRequest $request, Document $document)
{
$this->authorize('update', $document);
$file = $request->file('document');
$oldPath = $document->path;
$newPath = $file->store('documents', 'local');
DB::transaction(function () use ($document, $request, $file, $newPath) {
$document->update([
'original_name' => $file->getClientOriginalName(),
'path' => $newPath,
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
]);
});
try {
if ($oldPath && Storage::disk('local')->exists($oldPath)) {
Storage::disk('local')->delete($oldPath);
}
} catch (\Throwable $e) {
Log::warning('old_document_delete_failed', [
'document_id' => $document->id,
'old_path' => $oldPath,
'error' => $e->getMessage(),
]);
}
return response()->json(['message' => 'Dokumen berhasil diperbarui.']);
}Alasan penghapusan file lama dilakukan setelah update sukses adalah untuk mencegah kehilangan data bila operasi update gagal di tengah jalan. Kekurangannya, bisa terjadi file yatim jika delete gagal. Itu masih lebih aman daripada kehilangan referensi ke satu-satunya file yang valid.
Penanganan malware relay
Validasi tipe file tidak sama dengan pemeriksaan malware. Jika aplikasi menerima file dari pihak luar dan file itu nanti akan diunduh user lain, pertimbangkan kontrol tambahan:
- Quarantine sementara sebelum file dipublikasikan.
- Scanning asynchronous menggunakan service atau engine antivirus eksternal.
- Status file seperti
pending,clean, ataublockeddi database. - Blokir download sampai file dinyatakan aman.
Pendekatan ini menambah kompleksitas dan latensi, jadi biasanya diterapkan pada sistem dengan risiko tinggi seperti portal dokumen publik, lampiran email, atau file dari pihak ketiga yang tidak dipercaya.
Kesalahan umum yang sering lolos code review
- Hanya validasi di frontend dan menganggap input browser dapat dipercaya.
- Menggunakan
getClientOriginalName()sebagai nama file simpan. - Menyimpan upload ke direktori public tanpa kebutuhan nyata.
- Tidak membatasi ukuran file di aplikasi maupun server.
- Whitelist tipe file terlalu luas, misalnya menerima banyak format yang sebenarnya tidak dibutuhkan.
- Tidak ada authorization, sehingga user login bisa mengunggah atau mengunduh file milik resource lain.
- Tidak ada rate limit pada endpoint upload.
- Menghapus file lama sebelum file baru benar-benar aktif.
- Mengandalkan ekstensi file saja tanpa pemeriksaan MIME.
- Tidak menonaktifkan eksekusi skrip di direktori upload.
Checklist hardening Laravel file upload aman
- Gunakan Form Request untuk validasi server-side.
- Batasi file dengan aturan file, max, mimes, dan bila perlu mimetypes.
- Verifikasi tambahan MIME dan ekstensi untuk alur sensitif.
- Gunakan nama file acak dari
store()atau mekanisme aman setara. - Simpan file ke storage non-public.
- Sajikan file melalui controller atau signed URL dengan masa berlaku pendek.
- Terapkan auth dan authorization policy.
- Tambahkan rate limit khusus untuk endpoint upload.
- Pastikan direktori upload tidak mengeksekusi skrip.
- Catat audit log untuk upload, replace, delete, dan download sensitif.
- Gunakan flow replace yang aman: simpan baru dulu, hapus lama belakangan.
- Pertimbangkan antivirus scanning atau quarantine untuk skenario berisiko tinggi.
Penutup
Fitur upload file terlihat sederhana, tetapi permukaan serangnya luas. Di Laravel, pengamanan yang efektif biasanya bukan berasal dari satu validasi tunggal, melainkan gabungan kontrol: validasi server-side, pembatasan tipe dan ukuran, storage privat, penyajian terkontrol, larangan execute, rate limit, serta logging yang memadai.
Jika Anda sedang mereview endpoint upload yang sudah ada, mulai dari tiga hal paling berdampak: jangan percaya nama file asli, jangan simpan file sensitif di public, dan jangan buka endpoint upload tanpa auth dan rate limit. Tiga perbaikan ini saja sudah menutup banyak celah yang paling sering dimanfaatkan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!