Hardening upload file di Laravel perlu dilakukan di beberapa lapisan, bukan hanya memeriksa ekstensi file. Ancaman yang umum adalah spoofed MIME, ekstensi ganda seperti invoice.pdf.php, file terlalu besar, path traversal, malware, file yang tidak sengaja terekspos publik, dan penyalahgunaan endpoint upload secara berulang.
Pendekatan yang aman adalah membuat alur upload end-to-end: validasi request, verifikasi tipe file berbasis konten, pembatasan ukuran dan dimensi, penamaan file acak, penyimpanan di disk privat, pembuatan URL sementara saat file perlu diakses, pemindaian antivirus secara asynchronous, serta logging dan rate limiting. Dengan begitu, kalau satu lapisan lolos, masih ada lapisan lain yang menahan dampaknya.
Model ancaman upload file yang perlu ditangani
Sebelum menulis kode, tentukan dulu ancaman yang realistis pada fitur upload Anda. Ini membantu memilih kontrol yang tepat, bukan sekadar menambah validasi tanpa arah.
- Spoofed MIME: klien mengirim header atau metadata file yang mengaku sebagai
image/jpeg, padahal isinya bukan gambar. - Ekstensi ganda: nama seperti
avatar.jpg.phpataureport.pdf.exeuntuk mengecoh validasi dangkal. - File terlalu besar: menghabiskan bandwidth, memori, storage, atau waktu proses.
- Path traversal: nama file atau path yang mencoba keluar dari direktori tujuan.
- Malware: dokumen atau arsip yang membawa payload berbahaya.
- Public exposure: file sensitif tersimpan di lokasi publik dan bisa ditebak URL-nya.
- Abuse berulang: upload spam, brute force, atau upaya memenuhi storage.
Prinsip penting: anggap semua file dari user adalah tidak tepercaya sampai lolos validasi, disimpan di lokasi aman, dan bila perlu lolos scan.
Alur aman end-to-end untuk upload file di Laravel
Alur berikut cukup umum dan praktis dipakai pada aplikasi Laravel:
- Terima file lewat Form Request.
- Validasi ukuran, tipe file yang diizinkan, dan dimensi jika file gambar.
- Verifikasi MIME berbasis konten, bukan hanya nama file.
- Simpan file dengan nama acak di disk privat.
- Simpan metadata ke database dengan status awal, misalnya
pending_scan. - Dispatch job queue untuk scan antivirus.
- Setelah scan bersih, ubah status menjadi
ready. Jika terdeteksi ancaman, hapus atau karantina file lalu tandairejected. - Saat file perlu diunduh, buat URL sementara atau proxy download melalui controller yang memeriksa otorisasi.
- Log semua kejadian penting dan pasang rate limiting pada endpoint upload.
Alur ini lebih aman dibanding langsung menyimpan file ke folder publik lalu menganggap validasi request sudah cukup.
Validasi request Laravel yang aman
Gunakan Form Request, bukan logika acak di controller
Pisahkan validasi ke kelas khusus agar aturan mudah diuji dan dirawat. Contoh berikut memakai kombinasi rule file, batas ukuran, daftar MIME yang diizinkan, dan dimensi untuk gambar.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUploadRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'document' => [
'required',
'file',
'max:5120', // KB = 5 MB
'mimetypes:image/jpeg,image/png,application/pdf',
],
'image' => [
'nullable',
'file',
'max:2048',
'mimetypes:image/jpeg,image/png',
'dimensions:min_width=200,min_height=200,max_width=4000,max_height=4000',
],
];
}
}
Beberapa catatan penting:
maxpada file biasanya dihitung dalam KB, jadi pastikan tidak salah asumsi menjadi byte atau MB.mimetypeslebih berguna daripada hanya memeriksa ekstensi. Namun ini tetap belum cukup jika Anda ingin hardening yang lebih ketat.dimensionspenting untuk gambar agar mencegah file yang terlalu besar dari sisi resolusi, bukan hanya ukuran byte.
Kenapa rule bawaan belum cukup
Validasi Laravel membantu menolak banyak input buruk, tetapi Anda tetap perlu lapisan tambahan karena:
- MIME bisa dipalsukan pada sisi klien.
- Nama file asli dari user tidak boleh dipercaya.
- Beberapa file valid secara format tetap bisa berbahaya secara isi.
Karena itu, setelah request lolos validasi dasar, lakukan pengecekan MIME berbasis konten di server.
Pengecekan MIME berbasis konten dan penolakan nama file berbahaya
Jangan percaya nama file asli
Jangan gunakan getClientOriginalName() sebagai nama penyimpanan akhir. Nama asli boleh disimpan sebagai metadata untuk ditampilkan ke user, tetapi jangan dijadikan path atau filename utama karena berisiko pada karakter aneh, ekstensi ganda, dan collision.
Laravel menyediakan cara aman untuk menghasilkan nama acak, misalnya lewat hashName() atau kombinasi UUID dan ekstensi yang sudah diputuskan server.
Verifikasi MIME dari isi file
Untuk hardening upload file di Laravel, verifikasi tipe file dari isi aktual file lebih aman dibanding hanya memakai nama atau header yang datang dari klien. PHP dan Symfony di balik Laravel umumnya bisa mengenali MIME dari file yang diunggah, dan Anda bisa mempersempitnya dengan whitelist internal aplikasi.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreUploadRequest;
use App\Jobs\ScanUploadedFile;
use App\Models\UploadedFile;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UploadController extends Controller
{
public function store(StoreUploadRequest $request): JsonResponse
{
$file = $request->file('document');
$detectedMime = $file->getMimeType();
$allowedMimes = [
'image/jpeg',
'image/png',
'application/pdf',
];
if (! in_array($detectedMime, $allowedMimes, true)) {
return response()->json([
'message' => 'Tipe file tidak diizinkan.',
], 422);
}
$extensionMap = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
$extension = $extensionMap[$detectedMime];
$path = 'uploads/pending/' . Str::uuid() . '.' . $extension;
Storage::disk('private')->putFileAs(
'uploads/pending',
$file,
basename($path)
);
$uploaded = UploadedFile::create([
'user_id' => $request->user()->id,
'original_name' => $file->getClientOriginalName(),
'stored_path' => $path,
'mime_type' => $detectedMime,
'size' => $file->getSize(),
'status' => 'pending_scan',
]);
ScanUploadedFile::dispatch($uploaded->id);
return response()->json([
'id' => $uploaded->id,
'status' => $uploaded->status,
], 201);
}
}
Pola di atas melakukan beberapa hal penting:
- Menentukan MIME yang diizinkan lewat whitelist server-side.
- Menentukan ekstensi berdasarkan MIME hasil deteksi server, bukan dari nama file input.
- Menghasilkan nama file acak sehingga ekstensi ganda dari user tidak relevan.
- Menyimpan file ke lokasi privat dengan status
pending_scan.
Tentang ekstensi ganda dan path traversal
Jika Anda tidak pernah memakai nama file asli sebagai path penyimpanan, maka serangan seperti ../../../.env atau avatar.jpg.php kehilangan banyak efektivitasnya. Risiko tetap ada jika Anda menulis path secara manual dari input user, jadi aturan amannya sederhana:
- Jangan concatenation path dari input mentah user.
- Gunakan nama file acak yang dibuat server.
- Simpan nama file asli hanya sebagai metadata tampilan.
Simpan di disk privat, bukan folder publik
Konfigurasi filesystem
Kesalahan umum adalah menyimpan upload langsung ke public atau folder yang diekspos web server. Itu cocok untuk aset statis yang memang publik, tetapi tidak aman untuk file upload yang belum diverifikasi.
Gunakan disk privat yang berada di luar jalur publik atau setidaknya tidak dilayani langsung oleh web server. Contoh konfigurasi:
<?php
return [
'disks' => [
'private' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'throw' => false,
],
],
];
Dengan konfigurasi seperti ini, file tidak otomatis bisa diakses lewat URL publik. Akses harus melalui aplikasi, sehingga Anda bisa menambahkan otorisasi, audit, dan pembatasan akses.
Download aman dengan URL sementara atau controller proxy
Untuk file yang sudah lolos scan dan boleh diakses, ada dua pendekatan umum:
- Temporary URL jika storage driver mendukungnya.
- Controller proxy yang memeriksa autentikasi dan otorisasi lalu melakukan stream file.
Contoh sederhana melalui controller:
<?php
namespace App\Http\Controllers;
use App\Models\UploadedFile;
use Illuminate\Support\Facades\Storage;
class DownloadUploadController extends Controller
{
public function show(UploadedFile $uploadedFile)
{
$this->authorize('view', $uploadedFile);
abort_unless($uploadedFile->status === 'ready', 404);
return Storage::disk('private')->download(
$uploadedFile->stored_path,
$uploadedFile->original_name
);
}
}
Keuntungan pendekatan ini:
- File tidak pernah terbuka permanen ke publik.
- Akses bisa dibatasi berdasarkan user, role, organisasi, atau masa berlaku.
- Setiap download dapat dicatat ke log atau audit trail.
Scan antivirus secara asynchronous via queue
Kenapa scan sebaiknya async
Scan antivirus bisa memakan waktu dan membuat endpoint upload terasa lambat jika dilakukan langsung di request. Karena itu, lebih aman dan lebih nyaman untuk UX jika file diterima lebih dulu ke area privat, ditandai pending_scan, lalu diproses melalui queue.
Pola ini juga memudahkan retry jika scanner bermasalah, dan memisahkan beban CPU atau I/O dari jalur request utama.
Contoh job untuk scan file
Implementasi scanner bisa memakai layanan internal, container terpisah, atau antivirus seperti ClamAV. Detail integrasi bergantung lingkungan Anda, jadi contoh berikut memakai abstraksi service agar tidak mengunci ke satu tool tertentu.
<?php
namespace App\Jobs;
use App\Models\UploadedFile;
use App\Services\AntivirusScanner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ScanUploadedFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $uploadedFileId)
{
}
public function handle(AntivirusScanner $scanner): void
{
$uploaded = UploadedFile::findOrFail($this->uploadedFileId);
if ($uploaded->status !== 'pending_scan') {
return;
}
$absolutePath = Storage::disk('private')->path($uploaded->stored_path);
$result = $scanner->scan($absolutePath);
if ($result->isClean()) {
$uploaded->update([
'status' => 'ready',
'scanned_at' => now(),
]);
Log::info('upload.scan_clean', [
'upload_id' => $uploaded->id,
'user_id' => $uploaded->user_id,
'path' => $uploaded->stored_path,
]);
return;
}
Storage::disk('private')->delete($uploaded->stored_path);
$uploaded->update([
'status' => 'rejected',
'scan_result' => $result->signature(),
'scanned_at' => now(),
]);
Log::warning('upload.scan_rejected', [
'upload_id' => $uploaded->id,
'user_id' => $uploaded->user_id,
'signature' => $result->signature(),
]);
}
}
Hal yang perlu diperhatikan:
- Jangan pernah menyajikan file ke user sebelum scan selesai jika file termasuk kategori berisiko.
- Putuskan apakah file berbahaya akan dihapus atau dipindah ke area karantina untuk analisis forensik.
- Tambahkan timeout, retry, dan monitoring queue agar file tidak terjebak di status
pending_scan.
Trade-off scan synchronous vs asynchronous
- Synchronous: hasil langsung diketahui, tetapi request lebih lambat dan lebih rentan timeout.
- Asynchronous: UX upload lebih responsif dan skalanya lebih baik, tetapi status file menjadi bertahap dan aplikasi perlu menangani state
pending.
Untuk banyak aplikasi bisnis, async adalah pilihan yang lebih realistis.
Rate limiting, logging, dan kontrol abuse
Batasi endpoint upload
Upload adalah endpoint mahal dari sisi bandwidth, storage, dan pemrosesan. Karena itu, rate limiting bukan hanya fitur API umum, tetapi bagian dari hardening.
Anda bisa membatasi berdasarkan user atau IP. Contoh sederhana definisi limiter:
<?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),
];
});
}
}
Lalu terapkan pada route upload Anda. Angka tepatnya bergantung pada kebutuhan aplikasi, ukuran file, dan profil traffic. Jangan menyalin angka secara buta.
Log yang benar-benar berguna
Logging upload sebaiknya fokus pada kejadian yang membantu investigasi dan operasional:
- Upload diterima: user, IP, ukuran, MIME hasil deteksi.
- Validasi gagal: alasan, rule yang gagal, dan endpoint terkait.
- Scan selesai: status bersih atau ditolak, signature jika ada.
- Download file: siapa yang mengakses dan file mana.
- Rate limit terpicu atau anomali perilaku.
Hindari mencatat data sensitif yang tidak perlu. Metadata seperlunya biasanya cukup.
Kesalahan umum saat mengamankan upload file
- Hanya memeriksa ekstensi tanpa memeriksa MIME atau isi file.
- Menyimpan file di public disk sebelum verifikasi dan scan selesai.
- Memakai nama file asli user sebagai nama penyimpanan.
- Tidak membatasi ukuran dan dimensi, sehingga server terbebani file besar atau gambar dengan resolusi ekstrem.
- Tidak menangani status file seperti
pending_scan,ready, danrejected. - Tidak memasang rate limiting pada endpoint upload.
- Tidak memikirkan cleanup untuk file yatim, file gagal scan, atau job yang gagal.
Tips debugging saat validasi atau scan bermasalah
MIME terdeteksi tidak sesuai
Jika file yang tampak valid ditolak, cek nilai MIME yang benar-benar terdeteksi server. Jangan berasumsi MIME dari browser pasti sama dengan hasil deteksi di server. Log nilai getMimeType() untuk sampel file yang bermasalah dan sesuaikan whitelist bila memang format itu sah.
Upload gagal untuk file besar
Kalau validasi Laravel tampak benar tetapi file besar tetap gagal, periksa juga batas pada web server, PHP, reverse proxy, dan timeout request. Hardening upload bukan hanya urusan kode aplikasi; batas infrastruktur juga menentukan perilaku endpoint.
Job scan tidak jalan
Jika status file terus pending_scan, periksa worker queue, koneksi queue, log kegagalan job, dan apakah service scanner bisa diakses dari environment worker. Masalah ini sering bukan pada controller upload, melainkan pada jalur asinkronnya.
Checklist hardening upload file di Laravel
- Gunakan Form Request untuk validasi terstruktur.
- Whitelist tipe file yang diizinkan, jangan blacklist tipe berbahaya.
- Verifikasi MIME berbasis konten di server.
- Batasi ukuran file dan, untuk gambar, batasi dimensinya.
- Jangan gunakan nama file asli sebagai path penyimpanan.
- Gunakan nama file acak dan tentukan ekstensi dari MIME hasil deteksi server.
- Simpan file di disk privat, bukan folder publik.
- Sajikan file lewat URL sementara atau controller yang memeriksa otorisasi.
- Tambahkan status lifecycle file:
pending_scan,ready,rejected. - Lakukan scan antivirus via queue untuk menghindari request lambat.
- Log upload, scan, download, dan kegagalan penting.
- Terapkan rate limiting pada endpoint upload.
- Bersihkan file gagal scan, file yatim, dan job yang gagal.
Penutup
Hardening upload file di Laravel bukan satu rule validasi, melainkan rangkaian kontrol yang saling melengkapi. Validasi request menahan input buruk yang umum, verifikasi MIME berbasis konten mengurangi spoofing, penyimpanan privat mencegah eksposur langsung, scan async menangani malware tanpa merusak UX, dan rate limiting menahan abuse berulang.
Kalau Anda ingin hasil yang praktis dan aman, mulai dari empat hal ini terlebih dahulu: whitelist MIME, nama file acak, simpan di disk privat, dan scan via queue. Setelah itu, tambahkan logging, URL sementara, serta kontrol operasional seperti cleanup dan monitoring queue.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!