Laravel: sanitasi file upload dengan re-encode dan scan antiancam bukan sekadar menambahkan rule mimes pada validator. Jika aplikasi menerima file dari pengguna, Anda harus menganggap file itu tidak tepercaya sampai lolos beberapa lapisan pemeriksaan: ukuran, tipe konten, lokasi penyimpanan, status karantina, dan bila perlu pemindaian antivirus.
Masalah utamanya bukan hanya file yang “salah format”, tetapi juga polyglot file, spoof MIME, payload terlalu besar, dan malware sederhana yang lolos jika Anda hanya memeriksa ekstensi. Pendekatan yang lebih aman di Laravel adalah: validasi awal di request, simpan ke lokasi non-publik dengan nama acak, tandai sebagai quarantine, lakukan re-encode untuk tipe tertentu seperti gambar, scan file secara asinkron, lalu izinkan akses unduh hanya setelah statusnya dinyatakan aman.
Threat model upload yang perlu diasumsikan
Sebelum menulis kode, tentukan ancaman yang realistis terhadap endpoint upload:
- Ekstensi palsu: file
malware.exediubah menjadiinvoice.pdf. - Spoof MIME: header atau metadata request mengklaim file sebagai JPEG, padahal isi sebenarnya berbeda.
- Polyglot file: satu file valid untuk lebih dari satu parser, misalnya terlihat seperti gambar tetapi membawa konten lain yang berbahaya.
- Oversized payload: file besar untuk menghabiskan bandwidth, storage, memori, atau waktu proses.
- Malware sederhana: skrip, macro, atau file yang dikenali signature antivirus.
- Abuse endpoint: spam upload, brute-force storage, atau percobaan upload massal dari bot.
Implikasinya: validasi tunggal tidak cukup. Anda memerlukan defense in depth.
Prinsip arsitektur upload yang lebih aman di Laravel
1. Gunakan whitelist, bukan blacklist
Alih-alih menolak beberapa tipe berbahaya, lebih aman menerima hanya tipe file yang memang dibutuhkan. Misalnya, jika aplikasi hanya butuh avatar dan PDF lampiran, jangan izinkan ZIP, SVG, atau dokumen Office tanpa alasan bisnis yang jelas.
2. Simpan file di luar public root
Jangan simpan file upload langsung ke folder yang dapat dieksekusi atau diakses publik secara langsung. Simpan di disk terpisah, lalu layani file melalui controller atau URL bertanda tangan jika memang perlu diunduh.
3. Nama file acak dan path aman
Jangan gunakan nama file asli pengguna sebagai nama file storage. Nama asli boleh disimpan sebagai metadata untuk ditampilkan, tetapi file fisik sebaiknya memakai nama acak agar mengurangi collision, path traversal, dan kebocoran informasi.
4. Status karantina sebagai default
File yang baru diunggah sebaiknya berstatus quarantine sampai semua pemeriksaan selesai. Jangan langsung membuat file dapat diakses publik setelah upload sukses.
5. Re-encode untuk gambar
Untuk gambar, strategi aman yang umum adalah decode lalu encode ulang. Tujuannya bukan membuat file “steril total”, tetapi mengurangi risiko metadata berbahaya, struktur file aneh, dan payload non-standar yang lolos sebagai gambar.
6. Scan antivirus secara asinkron
Pemindaian antivirus bisa memakan waktu. Jika dilakukan sinkron di request utama, latensi endpoint upload bisa memburuk. Karena itu, pola yang lazim adalah simpan ke karantina lalu kirim job queue untuk scanning.
Validasi berlapis pada Form Request
Lapisan pertama adalah validasi request. Ini bukan lapisan terakhir, tetapi penting untuk menolak input yang jelas tidak valid sedini mungkin.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUploadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:5120', // 5 MB dalam KB
'extensions:jpg,jpeg,png,pdf',
'mimetypes:image/jpeg,image/png,application/pdf',
],
];
}
public function messages(): array
{
return [
'file.max' => 'Ukuran file melebihi batas.',
'file.extensions' => 'Ekstensi file tidak diizinkan.',
'file.mimetypes' => 'Tipe file tidak sesuai whitelist.',
];
}
}Ada beberapa catatan penting:
extensionsmembantu menyaring nama file, tetapi tidak boleh dianggap bukti tipe file.mimetypeslebih baik daripada hanya ekstensi, namun tetap bukan jaminan mutlak terhadap spoofing.- Batas ukuran harus konsisten dengan konfigurasi web server dan PHP agar request tidak gagal sebelum mencapai Laravel.
Untuk pemeriksaan yang lebih ketat, tambahkan validasi berbasis isi file setelah upload diterima. Laravel dapat membaca MIME yang terdeteksi dari file, tetapi untuk file sensitif Anda tetap perlu memverifikasi konten secara eksplisit sesuai tipe yang diizinkan.
Pemeriksaan tambahan di controller/service
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class UploadInspector
{
public function assertAllowed(UploadedFile $file): void
{
$detectedMime = $file->getMimeType();
$allowed = [
'image/jpeg',
'image/png',
'application/pdf',
];
if (! in_array($detectedMime, $allowed, true)) {
throw new RuntimeException('Detected MIME tidak diizinkan.');
}
if ($file->getSize() === false || $file->getSize() <= 0) {
throw new RuntimeException('Ukuran file tidak valid.');
}
}
}Ini tetap bukan scanner malware, tetapi memberi lapisan tambahan terhadap spoofing sederhana.
Penyimpanan aman: disk privat, nama acak, dan metadata file
Sebaiknya siapkan disk khusus upload privat. Konsepnya: file disimpan di lokasi yang tidak diekspos langsung, lalu metadata disimpan di database.
<?php
return [
'disks' => [
'private_uploads' => [
'driver' => 'local',
'root' => storage_path('app/private/uploads'),
'throw' => false,
],
],
];Contoh struktur tabel metadata file:
idoriginal_namestored_namediskpathsizemime_detectedextension_originalstatus(quarantine,clean,rejected,infected)scan_resultuploaded_by
Contoh alur simpan file:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreUploadRequest;
use App\Jobs\ScanUploadedFile;
use App\Models\UploadedDocument;
use App\Services\UploadInspector;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UploadController
{
public function store(StoreUploadRequest $request, UploadInspector $inspector)
{
$file = $request->file('file');
$inspector->assertAllowed($file);
$storedName = (string) Str::uuid() . '.' . strtolower($file->getClientOriginalExtension());
$path = $file->storeAs('quarantine', $storedName, 'private_uploads');
$upload = UploadedDocument::create([
'original_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'disk' => 'private_uploads',
'path' => $path,
'size' => $file->getSize(),
'mime_detected' => $file->getMimeType(),
'extension_original' => strtolower($file->getClientOriginalExtension()),
'status' => 'quarantine',
'uploaded_by' => $request->user()->id,
]);
ScanUploadedFile::dispatch($upload->id);
return response()->json([
'id' => $upload->id,
'status' => $upload->status,
], 202);
}
}Poin penting dari implementasi ini:
- File tidak langsung dapat diakses publik.
- Nama file acak, bukan nama dari pengguna.
- Status awal adalah
quarantine. - Scanning dijalankan di queue agar endpoint tetap responsif.
Re-encode gambar untuk mengurangi risiko file berbahaya
Jika aplikasi menerima gambar, re-encode adalah langkah yang sangat berguna. Prinsipnya: baca gambar dengan library image, lalu tulis ulang menjadi file baru dengan format yang diizinkan. Ini membantu membuang metadata yang tidak diperlukan dan menormalkan struktur file.
Re-encode bukan pengganti antivirus. Ia efektif sebagai sanitasi format untuk gambar, tetapi tidak relevan untuk semua tipe file seperti PDF atau dokumen Office.
Kapan re-encode layak dipakai?
- Avatar pengguna
- Foto produk
- Lampiran gambar yang hanya perlu ditampilkan ulang
Untuk kasus ini, Anda bahkan bisa menolak file asli setelah berhasil menghasilkan versi hasil re-encode.
Contoh service re-encode gambar
Contoh berikut menunjukkan ide arsitekturnya. Implementasi detail bisa menyesuaikan library image yang Anda gunakan.
<?php
namespace App\Services;
use App\Models\UploadedDocument;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class ImageSanitizer
{
public function reencodeToJpeg(UploadedDocument $upload): string
{
$disk = Storage::disk($upload->disk);
$source = $disk->path($upload->path);
$imageData = file_get_contents($source);
if ($imageData === false) {
throw new RuntimeException('Gagal membaca file sumber.');
}
$image = imagecreatefromstring($imageData);
if ($image === false) {
throw new RuntimeException('File gambar tidak dapat didecode.');
}
$newRelativePath = 'sanitized/' . pathinfo($upload->stored_name, PATHINFO_FILENAME) . '.jpg';
$target = $disk->path($newRelativePath);
if (! imagejpeg($image, $target, 85)) {
imagedestroy($image);
throw new RuntimeException('Gagal menulis hasil re-encode.');
}
imagedestroy($image);
return $newRelativePath;
}
}Mengapa pendekatan ini membantu?
- Parser gambar melakukan decode terhadap konten aktual, bukan sekadar percaya pada ekstensi.
- File hasil akhir ditulis ulang dari representasi gambar yang sudah diparse.
- Metadata atau struktur non-esensial dari file asli cenderung tidak terbawa.
Namun ada trade-off:
- Kualitas bisa berubah jika Anda memaksa konversi ke JPEG.
- Transparansi PNG bisa hilang jika tidak ditangani khusus.
- File rusak atau sangat aneh mungkin gagal diproses, dan itu justru sinyal baik untuk ditolak.
Untuk gambar yang butuh transparansi, Anda bisa tetap melakukan re-encode ke PNG. Prinsip amannya tetap sama: decode lalu encode ulang.
Scan antivirus asinkron dengan queue dan status karantina
Scanning antivirus sebaiknya dijalankan di background. Pola umumnya:
- Upload diterima.
- File disimpan ke karantina.
- Job queue memindai file.
- Jika bersih, status diubah menjadi
clean. - Jika terdeteksi ancaman atau pemeriksaan gagal, status menjadi
infectedataurejected.
Contoh job scanning
<?php
namespace App\Jobs;
use App\Models\UploadedDocument;
use App\Services\ImageSanitizer;
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;
use Throwable;
class ScanUploadedFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $uploadId)
{
}
public function handle(ImageSanitizer $sanitizer): void
{
$upload = UploadedDocument::findOrFail($this->uploadId);
if ($upload->status !== 'quarantine') {
return;
}
$disk = Storage::disk($upload->disk);
$absolutePath = $disk->path($upload->path);
try {
// Pseudocode: panggil engine antivirus eksternal atau service scanner.
// $result = $scanner->scan($absolutePath);
$result = ['clean' => true, 'message' => 'No threat detected'];
if (str_starts_with($upload->mime_detected, 'image/')) {
$sanitizedPath = $sanitizer->reencodeToJpeg($upload);
$upload->path = $sanitizedPath;
}
$upload->status = $result['clean'] ? 'clean' : 'infected';
$upload->scan_result = $result['message'];
$upload->save();
} catch (Throwable $e) {
Log::warning('Upload scan failed', [
'upload_id' => $upload->id,
'error' => $e->getMessage(),
]);
$upload->status = 'rejected';
$upload->scan_result = 'Scan failed';
$upload->save();
}
}
}Di dunia nyata, job ini biasanya memanggil engine seperti service internal scanner, container terisolasi, atau antivirus yang dapat dipanggil lewat CLI/daemon. Hindari mengeksekusi file yang diunggah. Scanner hanya membaca dan menganalisis file.
Kapan file boleh dipakai?
Gunakan aturan sederhana: file hanya bisa diunduh atau diproses lebih lanjut jika status = clean. Ini memudahkan enforcement di banyak titik aplikasi.
Pembatasan akses download dan signed URL
Karena file disimpan di disk privat, akses unduhan sebaiknya melalui endpoint yang memeriksa otorisasi dan status file.
<?php
namespace App\Http\Controllers;
use App\Models\UploadedDocument;
use Illuminate\Support\Facades\Storage;
class DownloadController
{
public function show(UploadedDocument $upload)
{
abort_unless($upload->status === 'clean', 404);
// Tambahkan policy/authorization sesuai kebutuhan.
return Storage::disk($upload->disk)->download(
$upload->path,
$upload->original_name
);
}
}Jika Anda memakai object storage, signed URL dapat dipertimbangkan untuk akses sementara. Tetap pastikan URL hanya dibuat untuk file yang sudah clean dan hanya bagi pengguna yang berhak.
Keuntungan signed URL:
- Tidak perlu stream file lewat aplikasi untuk setiap unduhan.
- Bisa dibatasi waktu aksesnya.
Kekurangannya:
- Anda harus hati-hati agar URL tidak dibuat untuk file karantina.
- Kontrol audit per-unduhan bisa lebih terbatas jika seluruh trafik langsung ke storage.
Rate limit endpoint upload dan pencegahan abuse
Upload sering menjadi target abuse karena mahal di sisi bandwidth, CPU, dan storage. Rate limit bukan fitur tambahan, tetapi bagian dari desain keamanan.
<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('uploads', function (Request $request) {
$key = $request->user()?->id ?: $request->ip();
return [
Limit::perMinute(10)->by($key),
];
});Lalu terapkan middleware pada route upload. Kombinasikan dengan:
- Batas ukuran file di aplikasi dan reverse proxy
- Autentikasi untuk endpoint sensitif
- Kuota storage per user/tenant
- Timeout scanning agar job tidak menggantung
- Pembersihan file karantina lama yang tidak pernah lolos scan
Logging, audit, dan observability
Jika terjadi insiden atau false positive, Anda perlu jejak audit yang jelas. Minimal log peristiwa berikut:
- Siapa yang mengunggah file
- Nama asli, ukuran, MIME terdeteksi
- Status transisi:
quarantinekeclean/infected/rejected - Hasil scan dan error scanning
- Upaya unduh file yang ditolak
Contoh data yang layak dicatat:
<?php
Log::info('File uploaded', [
'upload_id' => $upload->id,
'user_id' => $upload->uploaded_by,
'status' => $upload->status,
'mime' => $upload->mime_detected,
'size' => $upload->size,
]);Jangan log isi file atau data sensitif secara mentah. Fokus pada metadata operasional.
Kesalahan umum yang sering terjadi
1. Hanya memeriksa ekstensi file
Ini terlalu lemah. Ekstensi mudah dipalsukan.
2. Menyimpan file upload di public path
Risikonya meningkat jika file dapat diakses langsung atau bahkan dieksekusi oleh server yang salah konfigurasi.
3. Langsung menganggap upload sukses berarti aman
Upload berhasil hanya berarti file tersimpan, bukan aman dipakai.
4. Memproses file sebelum lolos karantina
Misalnya langsung menampilkan PDF atau meneruskan file ke sistem lain sebelum scan selesai.
5. Tidak membatasi ukuran dan frekuensi upload
Ini membuka peluang denial-of-service skala kecil yang sering luput dari perhatian.
Tips debugging jika alur upload bermasalah
- File ditolak padahal valid: bandingkan ekstensi, MIME dari browser, dan MIME yang terdeteksi server.
- Upload gagal sebelum masuk Laravel: cek batas ukuran di PHP dan web server.
- Queue scan tidak jalan: pastikan worker aktif dan job tidak gagal diam-diam.
- Re-encode gagal: kemungkinan file memang rusak, parser tidak mendukung format tertentu, atau memori tidak cukup.
- File tetap bisa diakses saat karantina: audit route download, signed URL, dan policy authorization.
Rekomendasi alur akhir yang praktis
Jika Anda ingin baseline yang kuat dan tetap realistis untuk banyak aplikasi Laravel, gunakan urutan berikut:
- Validasi request: required, ukuran maksimum, whitelist ekstensi, whitelist MIME.
- Verifikasi tambahan di server berdasarkan isi file yang terdeteksi.
- Simpan ke disk privat dengan nama acak.
- Tulis metadata ke database dengan status
quarantine. - Dispatch job queue untuk scanning.
- Untuk gambar, lakukan re-encode dan pakai file hasil sanitasi sebagai artefak final.
- Ubah status ke
cleanhanya jika semua pemeriksaan lolos. - Layanan unduh hanya untuk file
clean, melalui controller atau signed URL terbatas. - Tambahkan rate limit, logging, dan pembersihan berkala untuk file karantina.
Pendekatan ini tidak menjamin keamanan absolut, tetapi secara praktis jauh lebih kuat daripada upload langsung ke folder publik dengan validasi satu lapis. Dalam konteks Laravel, kombinasi Form Request + storage privat + queue scanning + status karantina + re-encode gambar adalah fondasi yang masuk akal untuk mengurangi risiko file berbahaya dan abuse pada endpoint upload.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!