Fitur upload adalah tempat aplikasi secara sengaja menerima berkas dari pihak tak dikenal dan menyimpannya di server kita — lokasi yang menarik untuk dijadikan markas. Chapter Verifikasi File Upload
sudah membahas lapis validasi dasar: rule mimes yang membaca isi berkas dan max untuk ukuran. Chapter ini membedah sisi penyerangnya secara sistematis: ekstensi ganda yang menipu konfigurasi web server, berkas polyglot yang berpura-pura jadi gambar, SVG yang membawa skrip, hingga usaha path traversal — semuanya diuji nyata, lengkap dengan pertahanan yang dibuktikan bekerja.
Peta Ancaman Upload #
| Vektor serangan | Triknya | Pertahanan utama |
|---|---|---|
| Ekstensi ganda | photo.php.jpg di server yang mengeksekusi .php | Nama acak + ekstensi dari isi berkas |
| MIME dusta | Header multipart mengaku image/jpeg | Validasi baca isi, bukan klaim klien |
| Polyglot | Header JPEG + payload PHP dalam satu berkas | Simpan di luar public, jangan eksekusi |
| SVG beracun | <script> di dalam berkas gambar | Sanitasi atau sajikan sebagai unduhan |
| Path traversal | ../../ pada nama berkas | Sanitasi nama + normalisasi storage |
| Null byte | foto\0.php memotong nama di level C | Ditolak modern storage layer |
Ekstensi Ganda dan Dusta MIME #
Kita uji nama berkas klasik photo.php.jpg yang isinya JPEG sungguhan (diawali magic number FF D8 FF):
use Symfony\Component\HttpFoundation\File\UploadedFile;
// berkas JPEG asli, sengaja diberi nama photo.php.jpg
$file = new UploadedFile($tmp, 'photo.php.jpg', 'image/jpeg', null, true);
echo 'nama klien : '.$file->getClientOriginalName()."\n";
echo 'ekstensi tebakan : '.var_export($file->guessExtension(), true)."\n";
echo 'mime isi berkas : '.var_export($file->getMimeType(), true)."\n";Output yang Dihasilkan:
nama klien : photo.php.jpg
ekstensi tebakan : 'jpg'
mime isi berkas : 'image/jpeg'Bahaya ekstensi ganda bukan pada Laravel, melainkan pada konfigurasi web server: Apache dengan AddHandler era lama mengeksekusi berkas sebagai PHP bila salah satu ekstensinya .php, sehingga photo.php.jpg dijalankan sebagai skrip. Nginx dengan cgi.fix_pathinfo longgar punya varian masalah serupa. guessExtension() yang membaca isi berkas mengembalikan jpg — dan rule mimes:jpg,png meloloskan berkas ini karena memang isinya JPEG asli.
Ancaman yang lebih licik adalah polyglot — berkas yang sah di mata detektor namun membawa payload. Kita tempel kode PHP setelah header JPEG:
file_put_contents($tmp, hex2bin('ffd8ffe000104a46494600010100000100010000ffdb004300')
.str_repeat("\x00", 32).'<?php system($_GET["cmd"]); ?>');
$finfo = new finfo(FILEINFO_MIME_TYPE);
echo 'finfo menyatakan : '.$finfo->file($tmp)."\n";Output yang Dihasilkan:
finfo menyatakan : image/jpegDeteksi MIME menyatakan image/jpeg — benar, karena header JPEG memang sah — padahal payload PHP bersarang di belakangnya. Tidak ada validator yang bisa “memahami niat” berkas; karena itu pertahanan sejati polyglot ada di arsitektur: berkas user tidak pernah disimpan di lokasi yang bisa dieksekusi, dan tidak pernah dilayani dari public root dengan izin eksekusi.
SVG: Gambar yang Menjalankan Skrip #
SVG adalah format XML — dan XML bisa memuat <script>. Kita unggah avatar SVG berisi skrip:
$svg = '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
.'<script>alert(document.cookie)</script><circle r="50" cx="50" cy="50" fill="red"/></svg>';
$file = new UploadedFile($tmp, 'avatar.svg', 'image/svg+xml', null, true);
echo 'ekstensi tebakan : '.var_export($file->guessExtension(), true)."\n";
echo 'mime isi berkas : '.var_export($file->getMimeType(), true)."\n";Output yang Dihasilkan:
ekstensi tebakan : 'svg'
mime isi berkas : 'image/svg+xml'Validasi MIME tidak menolak berkas ini karena image/svg+xml memang MIME yang sah untuk SVG — masalahnya konten. Jika avatar itu dirender inline di halaman kita, alert(document.cookie) dieksekusi di domain aplikasi: XSS stored versi berkas. Tiga pilihan menanganinya: sanitasi elemen berbahaya dari SVG (misalnya lewat paket svgsanitizer), menyajikannya lewat domain terpisah, atau memaksa unduhan alih-alih render inline.
Path Traversal dan Null Byte #
Dua vektor klasik menyerang lokasi penyimpanan. Yang pertama, nama berkas yang menyusup ke path:
use Illuminate\Support\Facades\Storage;
// 1. developer merakit path sendiri dari nama user
Storage::disk('local')->put('../keluar-dari-root.txt', 'payload penyerang');
// 2. nama asli klien berisi traversal
$file = new UploadedFile($tmp, '../../serangan.jpg', 'image/jpeg', null, true);
Storage::disk('local')->putFileAs('unggahan', $file, $file->getClientOriginalName());Output yang Dihasilkan:
1. DITOLAK: League\Flysystem\PathTraversalDetected — Path traversal detected: ../keluar-dari-root.txt
2. path hasil: unggahan/serangan.jpgKasus pertama dilempar PathTraversalDetected — Flysystem menolak path yang mencoba keluar dari root disk. Kasus kedua lebih menarik: sebelum Laravel bekerja, Symfony sudah membuang seluruh bagian direktori dari nama klien dan menyisakan serangan.jpg saja — getClientOriginalName() hanya mengembalikan basename. Serangan kedua pernah memakai null byte (foto\0.php memotong nama di level bahasa C); layer storage modern menutupnya secara eksplisit:
Storage::disk('local')->put("serangan\0.php", 'payload');Output yang Dihasilkan:
DITOLAK: League\Flysystem\CorruptedPathDetected — Corrupted path detected: serangan .phpNama Acak dan Ekstensi dari Isi #
Pertahanan paling tenang justru yang paling sederhana: jangan pernah pakai nama dari klien untuk berkas di disk. Method putFile() mengganti nama berkas dengan hash acak 40 karakter dan mengekstensi dari isi berkas:
$il = new Illuminate\Http\UploadedFile(
$file->getRealPath(), $file->getClientOriginalName(), 'image/jpeg', null, true
);
$stored = Storage::disk('local')->putFile('unggahan', $il);
echo 'nama klien : '.$il->getClientOriginalName()."\n";
echo 'path tersimpan : '.$stored."\n";Output yang Dihasilkan (nama hash dipendekkan):
nama klien : photo.php.jpg
path tersimpan : unggahan/YglL4tIERH...maxLH.jpgPerhatikan hasilnya: .php lenyap, nama file tak lagi bisa diprediksi, dan ekstensi murni hasil tebakan isi. Penyerang kehilangan tiga hal sekaligus — nama, ekstensi, dan kemampuan menulis ke path pilihannya. Ditambah aturan penyajian: berkas privat disimpan di disk non-public (default disk local menunjuk ke storage/app/private, di luar document root) dan hanya dilayani lewat controller yang memeriksa kepemilikan.
Menyajikan Unduhan dengan Aman #
Saat berkas harus dikirim ke pengguna, response download memberi dua header penting:
$res = response()->download(
Storage::disk('local')->path('unggahan/avatar.svg'),
'avatar.svg',
['Content-Type' => 'image/svg+xml'],
);
echo 'header Content-Disposition: '.$res->headers->get('Content-Disposition')."\n";
echo 'header Content-Type : '.$res->headers->get('Content-Type')."\n";Output yang Dihasilkan:
header Content-Disposition: attachment; filename=avatar.svg...
header Content-Type : image/svg+xmlContent-Disposition: attachment memaksa unduhan alih-alih render inline — SVG beracun di atas menjadi berkas mati, bukan skrip berjalan. Content-Type yang eksplisit mencegah browser menebak jenis berkas (MIME sniffing di sisi klien). Terakhir, batasi ukuran lewat validasi (max:2048) dan post_max_size/upload_max_filesize di PHP agar berkas raksasa tidak menghabiskan resource — pembahasan lengkapnya di dokumentasi Filesystem
.
Kolom database, tampilan, kredensial, dan berkas kini punya pertahanan masing-masing. Sisa pekerjaan kita adalah mengunci pintu masuknya: sesi login yang rentan pembajakan dan enumerasi email — materi chapter berikutnya.