Fitur upload file berarti kamu mengizinkan orang asing menaruh file di server-mu. Mirip loket penitipan barang di stasiun: petugas yang baik gak cuma baca label di koper, dia cek isinya lewat mesin X-ray, menimbang beratnya, lalu menyimpannya di gudang belakang dengan nomor tiket, bukan di etalase depan dengan nama pemiliknya.
🧪 Tebakan cepat: ada file bernama
shell.jpg, tapi isinya kode PHP. Kalau server cuma mengecek ekstensi.jpg, file ini diterima atau ditolak? Tebak dulu, jawabannya ada di bagian "Label bisa bohong, isi file gak".
Kenapa upload itu berbahaya
File yang diupload datang dari luar, jadi semua tentangnya bisa diatur pengirim: nama, ekstensi, isi, dan ukurannya. Tanpa pengecekan, ada empat masalah klasik:
- Web shell: file PHP (atau JSP, ASP) yang dijalankan server kalau URL-nya dibuka. Penyerang yang berhasil menaruhnya bisa menyuruh server-mu melakukan apa saja.
- Nama file berbahaya: nama dari user bisa menimpa file lain yang namanya sama, atau mencoba keluar folder dengan
../../config.php(ini disebut path traversal). - File raksasa: satu upload 2 GB bisa menghabiskan disk, dan server berhenti melayani user lain (DoS).
- SVG atau HTML berisi script: kalau disajikan sebagai halaman di domain-mu, script-nya jalan di browser korban (XSS).
Label bisa bohong, isi file gak
Ekstensi (.jpg, .png) cuma bagian dari nama, jadi siapa pun bisa menggantinya. Header Content-Type yang dikirim browser juga gak bisa dipercaya: browser menebaknya dari ekstensi, jadi shell.jpg tetap dikirim sebagai image/jpeg.
Yang lebih jujur adalah magic bytes: beberapa byte pertama isi file yang jadi sidik jari formatnya. Ditulis dalam heksadesimal (angka basis 16, satu byte = dua digit):
- JPEG selalu dimulai
FF D8 FF. - PNG selalu dimulai
89 50 4E 47(byte 2 sampai 4 adalah hurufPNG). - WEBP dimulai
RIFF, dan byte ke-9 sampai 12 berisiWEBP. - File PHP dimulai teks
<?php, yaitu3C 3F 70 68 70.
Ini empat file contoh yang ada di Lab, diperiksa dengan tiga cek: ekstensi, magic bytes, dan ukuran maksimal 5 MB (5.242.880 byte).
| File | Ekstensi | Byte pertama | Isi menurut magic bytes | Ukuran | Hasil |
|---|---|---|---|---|---|
kucing.jpg |
jpg, lolos | FF D8 FF E0 |
JPEG | 240 KB | diterima |
shell.php |
php, gagal | 3C 3F 70 68 |
teks <?php |
64 byte | ditolak: ekstensi |
shell.jpg |
jpg, lolos | 3C 3F 70 68 |
teks <?php |
64 byte | ditolak: isi bukan gambar |
poster.png |
png, lolos | 89 50 4E 47 |
PNG | 8,4 MB | ditolak: lebih dari 5 MB |
Kalau server cuma mengecek ekstensi, shell.jpg diterima, karena namanya memang berakhiran .jpg. Itu jawaban tebakannya. Cek magic bytes yang menolaknya: isinya teks PHP, bukan JPEG.
Kode pengecekannya
const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB = 5.242.880 byte
// bytes: 12 byte pertama file, sebagai Uint8Array
function sniff(bytes) {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpg'
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png'
const riff = String.fromCharCode(...bytes.slice(0, 4))
const webp = String.fromCharCode(...bytes.slice(8, 12))
if (riff === 'RIFF' && webp === 'WEBP') return 'webp'
return null // bukan gambar yang kita izinkan
}
function checkUpload(name, size, bytes) {
const ext = name.split('.').pop().toLowerCase()
if (!ALLOWED_EXT.includes(ext)) return { ok: false, reason: 'ekstensi tidak diizinkan' }
const type = sniff(bytes)
if (!type) return { ok: false, reason: 'isi file bukan gambar' }
if (size > MAX_SIZE) return { ok: false, reason: 'lebih dari 5 MB' }
return { ok: true, saveAs: crypto.randomUUID() + '.' + type }
}
ALLOWED_EXTadalah allowlist: daftar yang boleh. Lebih aman daripada daftar yang dilarang, karena format berbahaya yang kamu lupa otomatis tertolak.name.split('.').pop()mengambil bagian setelah titik terakhir, jadifoto.php.jpgdibacajpg. Karena itu ekstensi saja gak cukup.sniff(bytes)membaca magic bytes. Di Node dengan multer,bytesdidapat darireq.file.buffer.subarray(0, 12). Di browser darinew Uint8Array(await file.slice(0, 12).arrayBuffer()).size > MAX_SIZEmenolak file kebesaran. Pasang juga batasnya di parser upload (limits: { fileSize: MAX_SIZE }di multer), supaya file 2 GB berhenti dibaca di tengah jalan, bukan setelah memenuhi memori.saveAsadalah nama baru yang dibuat server. Ekstensinya diambil dari hasilsniff, bukan dari nama kiriman user.
Simpan dengan nama acak, di luar folder public
Folder public (web root) adalah folder yang isinya bisa dibuka langsung lewat URL. Kalau shell.php sempat tersimpan di public/uploads/, siapa pun bisa membuka https://situsmu.com/uploads/shell.php dan server menjalankannya.
| Cara simpan | Lokasi file | Bisa dibuka lewat URL? |
|---|---|---|
| Nama asli, di public | public/uploads/kucing.jpg |
ya, https://situsmu.com/uploads/kucing.jpg |
| Nama acak, di luar public | storage/uploads/3f9c2a7e-5b1d-4c8e-9a40-6d2f81b7e0c4.jpg |
gak, cuma lewat route GET /files/:id |
- Nama acak (
crypto.randomUUID()) membuat nama dari user gak pernah dipakai: gak bisa menimpa file lain, gak bisa memilih ekstensi.php, dan gak bisa mencoba../../. - Di luar public mencegah file dijalankan atau dibuka langsung lewat URL. File disajikan lewat route milikmu yang bisa mengecek login dulu, dengan header
Content-Disposition: attachmentsupaya browser mengunduhnya, bukan menampilkannya sebagai halaman. Di Express,res.download(path)memasang header itu.
Di Laravel
$request->validate([
'avatar' => ['required', 'file', 'mimes:jpg,png,webp', 'max:5120'],
]);
// Disimpan di storage (di luar public) dengan nama acak 40 karakter
$path = $request->file('avatar')->store('avatars');
mimes:jpg,png,webpkelihatannya daftar ekstensi, tapi Laravel sebenarnya membaca isi file untuk menebak jenisnya.mimetypes:image/jpeg,image/pngmenulis jenisnya langsung. Untuk mengecek ekstensi yang diketik user, ada ruleextensions:jpg,png, dan itu harus selalu dipasangkan denganmimesataumimetypes.max:5120untuk file artinya 5.120 kilobyte, yaitu 5 MB.store('avatars')memberi nama acak dan menyimpan di disklocal, di dalam folderstorage, bukanpublic.- Kebalikannya,
$file->move(public_path(), $file->getClientOriginalName())memakai nama dari user dan menaruhnya di web root. Dua kesalahan sekaligus.
⚠️ Jebakan yang sering ketemu
- Cuma cek ekstensi atau
file.mimetype. Keduanya datang dari pengirim.shell.jpglolos dua-duanya. Cek magic bytes. - Memakai nama file dari user.
getClientOriginalName()ataufile.originalnamebisa berisishell.php, nama file yang sudah ada, atau../../config.php. Buat nama sendiri. - Menyimpan di folder public. Satu file yang lolos langsung bisa dijalankan lewat URL. Simpan di storage, sajikan lewat route.
- Lupa batas ukuran. Tanpa batas, satu upload raksasa bisa menghabiskan disk atau memori server.
TL;DR: Semua tentang file upload bisa diatur pengirim, jadi cek tiga hal: ekstensi lewat allowlist, isi lewat magic bytes (JPEG FF D8 FF, PNG 89 50 4E 47, PHP <?php), dan ukuran (misalnya maksimal 5 MB). Ekstensi dan Content-Type bisa bohong: shell.jpg lolos cek ekstensi tapi ditolak magic bytes. Jangan pakai nama asli dari user, karena bisa berbahaya (menimpa file, memilih .php, path traversal): buat nama acak. Simpan di luar web root (public) untuk mencegah eksekusi langsung lewat URL, dan sajikan lewat route dengan Content-Disposition: attachment. Di Laravel: mimes:jpg,png (membaca isi file), mimetypes, max:5120, dan store().