Secure File Upload — Security

Risiko File Upload Fitur upload file adalah salah satu attack vector terbesar di aplikasi web. Tanpa validasi yang tepat, penyerang bisa mengupload file…

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:

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

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 }
}

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

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');

⚠️ Jebakan yang sering ketemu

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().

Yang akan kamu pelajari