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 seranganTriknyaPertahanan utama
Ekstensi gandaphoto.php.jpg di server yang mengeksekusi .phpNama acak + ekstensi dari isi berkas
MIME dustaHeader multipart mengaku image/jpegValidasi baca isi, bukan klaim klien
PolyglotHeader JPEG + payload PHP dalam satu berkasSimpan di luar public, jangan eksekusi
SVG beracun<script> di dalam berkas gambarSanitasi atau sajikan sebagai unduhan
Path traversal../../ pada nama berkasSanitasi nama + normalisasi storage
Null bytefoto\0.php memotong nama di level CDitolak 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):

php
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:

text
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:

php
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:

text
finfo menyatakan  : image/jpeg

Deteksi 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:

php
$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:

text
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:

php
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:

text
1. DITOLAK: League\Flysystem\PathTraversalDetected — Path traversal detected: ../keluar-dari-root.txt
2. path hasil: unggahan/serangan.jpg

Kasus 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:

php
Storage::disk('local')->put("serangan\0.php", 'payload');

Output yang Dihasilkan:

text
DITOLAK: League\Flysystem\CorruptedPathDetected — Corrupted path detected: serangan .php

Nama 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:

php
$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):

text
nama klien         : photo.php.jpg
path tersimpan     : unggahan/YglL4tIERH...maxLH.jpg

Perhatikan 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:

php
$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:

text
header Content-Disposition: attachment; filename=avatar.svg...
header Content-Type       : image/svg+xml

Content-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.