Keamanan Siber

Upload File yang Aman untuk Aplikasi PHP Kecil — Kendalikan Nama, Tipe, Penyimpanan, dan Penyajian

Upload File yang Aman untuk Aplikasi PHP Kecil — Kendalikan Nama, Tipe, Penyimpanan, dan Penyajian

Form upload file terlihat seperti fitur kecil: menerima file, memindahkannya ke sebuah direktori, lalu menyimpan namanya. Pertanyaan sulitnya muncul satu langkah kemudian. Bagian mana dari file itu yang benar-benar diketahui aplikasi, dan bagian mana yang sekadar klaim dari client yang tidak tepercaya?

Jawaban paling aman bukanlah mencari satu pengujian yang sempurna. Kita perlu merancang jalur sempit agar setiap keputusan tetap berada di bawah kendali aplikasi: siapa yang boleh mengunggah, seberapa besar request yang diizinkan, format apa yang memang diperlukan, bagaimana objek yang disimpan diberi nama, di mana lokasinya, dan bagaimana objek itu dapat diambil. Artikel ini membahas jalur tersebut untuk aplikasi PHP kecil. Contoh kode singkat di sini tidak diklaim dapat membuat sembarang file menjadi aman.

Upload melewati lebih dari satu batas kepercayaan

Objek yang diunggah datang bersama bytes dan metadata. Dalam PHP, $_FILES dapat memuat nama asli, path sementara, ukuran, kode error, dan media type. Namun, dokumentasi upload PHP secara tegas menyatakan bahwa MIME type yang dilaporkan browser tidak diperiksa oleh PHP dan tidak boleh langsung dipercaya. Nama dan full path yang dikirim juga merupakan input dari client.

Pembedaan ini penting karena sebuah file dapat menimbulkan bahaya melalui beberapa cara. Server mungkin mengeksekusinya, parser gambar atau dokumen mungkin memiliki kerentanan, active content mungkin menyerang pembaca, objek yang sangat besar mungkin menghabiskan storage, atau nama file yang terlihat biasa mungkin menimpa objek yang sudah ada. CWE-434 menggambarkan kelemahan utamanya sebagai penerimaan jenis file berbahaya yang kemudian diproses oleh lingkungan penerima.

Karena itu, file tersebut hanya salah satu bagian dari masalah. Kombinasi yang berbahaya adalah content, interpretation, dan permission. File PHP yang berada di luar public tree dan tidak pernah diberikan kepada interpreter memiliki risiko berbeda dari bytes yang sama ketika ditempatkan di bawah URL tempat web server mengeksekusinya. Desain upload yang baik mengendalikan konteks tersebut, bukan berusaha mengenali setiap rangkaian bytes berbahaya yang mungkin ada.

Mulai dari kontrak berguna yang paling sempit

Sebelum menulis kode validasi, tentukan apa yang benar-benar dibutuhkan fitur. “Upload sebuah file” terlalu luas. “Editor yang sudah terautentikasi boleh mengunggah satu gambar JPEG atau PNG hingga 5 MiB untuk thumbnail artikel” adalah kontrak yang dapat diterapkan dan ditinjau.

Kontrak yang berguna setidaknya menjawab pertanyaan berikut:

  • Siapa yang boleh membuat, mengganti, membaca, dan menghapus objek?
  • Format spesifik apa yang dibutuhkan oleh fitur?
  • Berapa batas maksimum request, file, pixel, dan storage?
  • Apakah server akan melakukan parsing, resize, ekstraksi, atau transformasi lainnya?
  • Apakah objek harus bersifat public, private, sementara, atau disimpan dalam jangka tertentu?
  • Apa yang harus terjadi ketika validasi, penyimpanan, atau pemrosesan berikutnya gagal?

Allowlist mengikuti kontrak ini. Jika fitur hanya membutuhkan JPEG dan PNG, menerima SVG, HTML, PDF, ZIP, dan semua binary type yang tidak dikenal akan menambah attack surface tanpa menambah manfaat. OWASP File Upload Cheat Sheet menyarankan agar hanya extension yang dibutuhkan fungsi bisnis yang diizinkan. Denylist seperti “tolak .php” harus mengantisipasi setiap jenis berbahaya dan setiap cara lingkungan penyajian dapat menafsirkan sebuah nama.

Tolak kegagalan transfer sebelum memeriksa content

Penanganan upload pertama-tama perlu memastikan apakah PHP menerima upload secara lengkap. PHP menyediakan status melalui field error dan konstanta UPLOAD_ERR_*. Hanya UPLOAD_ERR_OK yang mewakili transfer berhasil. Upload parsial, direktori sementara yang hilang, atau kegagalan menulis bukan file yang perlu “tetap dicoba”; itu adalah request yang gagal.

Batas ukuran sebaiknya ada pada beberapa layer. Hidden field MAX_FILE_SIZE pada form dapat memperbaiki user experience, tetapi dokumentasi PHP memperingatkan bahwa client dapat mengubahnya. Pengaturan server seperti post_max_size dan upload_max_filesize memberi batas yang lebih awal, sedangkan kode aplikasi tetap memerlukan batas khusus untuk fitur tersebut. Web server atau reverse proxy juga mungkin memiliki batas request body sendiri.

Batas-batas ini tidak saling menggantikan. Batas infrastruktur menolak traffic yang jelas terlalu besar lebih awal; batas aplikasi menyatakan apa yang diterima fitur tertentu. Jika aplikasi kemudian melakukan decompress atau decode terhadap content, jumlah compressed bytes belum tentu menjadi batas resource yang relevan. Dimensi pixel, ukuran archive setelah diekstrak, processing time, dan total storage quota juga mungkin perlu diperhitungkan.

Perlakukan nama dan type sebagai bukti, bukan kebenaran

Nama file asli dapat berguna untuk tampilan atau catatan audit, tetapi nama itu tidak boleh menentukan storage path. Menghapus komponen direktori dengan basename() hanya menangani sebagian masalah. Nama dapat bertabrakan, memuat karakter yang merepotkan, menyembunyikan beberapa extension, atau memiliki arti khusus bagi tool lain. Identifier yang dibuat aplikasi membuat keputusan filesystem tetap terpisah dari user input.

Pemeriksaan type juga memerlukan lebih dari satu sinyal. Content-Type yang dinyatakan browser mudah dipalsukan. Extension Fileinfo milik PHP dapat memeriksa file sementara dan mengembalikan klasifikasi MIME melalui finfo_file(). Ini lebih berguna daripada memercayai request header, tetapi tetap merupakan klasifikasi, bukan sertifikat bahwa content tersebut tidak berbahaya.

Policy praktis dapat membandingkan allowlist extension yang kecil dengan allowlist MIME hasil pemeriksaan content yang juga kecil, lalu menolak ketidakcocokan. Untuk workflow gambar, melakukan decode dan rewrite gambar dengan library yang terawat dapat menambah satu kontrol lagi. Hal itu tetap tidak membuktikan keamanan secara universal: decoder adalah software, metadata dapat berimplikasi pada privasi, dan resource limit tetap berlaku. Pemeriksaan berlapis mengurangi ketidakpastian; pemeriksaan tersebut tidak mengubah hostile input menjadi trusted input hanya melalui sebuah deklarasi.

Jaga nama penyimpanan dan delivery di bawah kendali aplikasi

OWASP dan CWE-434 sama-sama menyarankan penyimpanan upload di luar web document root jika memungkinkan. Ini memutus jalur paling sederhana dari “server menerima bytes ini” menuju “web server akan menafsirkan bytes ini melalui URL public.” Host atau object store terpisah dapat membentuk batas yang lebih kuat, tetapi aplikasi kecil tetap memperoleh pemisahan yang berarti dengan memakai direktori non-public.

Private storage berarti download memerlukan handler. Langkah tambahan itu berguna: aplikasi dapat memetakan opaque ID ke storage name, memeriksa authorization, memilih response Content-Type yang aman, mengatur Content-Disposition, dan menentukan apakah inline rendering layak diizinkan. Nama asli dapat diberikan sebagai metadata download yang di-encode secara hati-hati tanpa berubah menjadi filesystem path.

Izin upload dan izin baca adalah dua hal terpisah. User yang boleh mengirim dokumen private tidak otomatis boleh mengambil setiap dokumen milik user lain. Demikian pula, editor yang boleh mengunggah thumbnail public belum tentu boleh mengganti sembarang media record. Authentication menjawab siapa user tersebut; authorization masih harus menjawab apa yang boleh dilakukan user itu terhadap objek spesifik ini. Request upload yang mengubah state juga memerlukan perlindungan CSRF yang biasa digunakan aplikasi.

Contoh PHP yang sengaja dibuat sempit

Contoh berikut menerima satu gambar JPEG atau PNG, membatasi ukurannya hingga 5 MiB, memperoleh MIME type dari file sementara, membuat storage name acak, dan memindahkannya ke direktori di luar web root. Direktori tersebut harus sudah ada dan dapat ditulis oleh PHP worker. Contoh ini tidak mencakup authentication, authorization, validasi CSRF, database persistence, image decoding, quota, dan cleanup karena kontrol-kontrol itu bergantung pada aplikasi di sekitarnya.

<?php
declare(strict_types=1);

const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const UPLOAD_DIRECTORY = '/srv/example-app/uploads';

if (!isset($_FILES['image']) || !is_array($_FILES['image'])) {
    throw new RuntimeException('Missing upload.');
}

$upload = $_FILES['image'];

if (($upload['error'] ?? null) !== UPLOAD_ERR_OK) {
    throw new RuntimeException('The upload did not complete successfully.');
}

$temporaryPath = $upload['tmp_name'] ?? '';
$actualSize = is_string($temporaryPath) ? filesize($temporaryPath) : false;

if ($actualSize === false || $actualSize < 1 || $actualSize > MAX_UPLOAD_BYTES) {
    throw new RuntimeException('The file size is not allowed.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($temporaryPath);
$allowedTypes = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
];

if (!is_string($mimeType) || !isset($allowedTypes[$mimeType])) {
    throw new RuntimeException('The file type is not allowed.');
}

$storageName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mimeType];
$destination = UPLOAD_DIRECTORY . DIRECTORY_SEPARATOR . $storageName;

if (!move_uploaded_file($temporaryPath, $destination)) {
    throw new RuntimeException('The uploaded file could not be stored.');
}

// Persist the opaque storage name and validated MIME type, not a user path.

Dokumentasi resmi menyatakan bahwa move_uploaded_file() memeriksa apakah sumbernya merupakan upload HTTP POST PHP yang valid. Ini adalah pemeriksaan provenance yang berguna, bukan validasi content. Dokumentasi itu juga memperingatkan bahwa destination yang sudah ada akan ditimpa. Nama acak 128-bit membuat tabrakan tidak disengaja menjadi sangat kecil kemungkinannya, tetapi sistem yang memerlukan jaminan mutlak untuk tidak menimpa membutuhkan storage semantics yang membuat destination secara eksklusif, bukan mengandalkan probabilitas.

Contoh tersebut juga tidak memercayai size atau type yang dilaporkan client. Kode mengukur file sementara dan menggunakan Fileinfo. Untuk fitur yang khusus menerima gambar, langkah selanjutnya mungkin berupa decode gambar, penerapan batas dimensi dan memory, pembuangan metadata yang tidak diperlukan, lalu penulisan derivative yang di-encode ulang. Tepat atau tidaknya langkah tersebut bergantung pada format yang dibutuhkan dan image library yang dipilih.

Validasi hanyalah awal dari lifecycle

File yang lolos admission check tetap dapat menimbulkan masalah operasional kemudian. Parser dan converter memerlukan update, timeout, memory limit, dan isolation yang sesuai dengan risikonya. Ekstraksi archive memerlukan batas terhadap content hasil ekstraksi dan path. Malware scanning dapat menambah bukti yang berguna, tetapi hasil bersih bukanlah bukti bahwa file tersebut benign. Mengirim upload private ke scanning service pihak ketiga justru dapat mengungkapkan data.

Storage juga memerlukan aturan lifecycle. Kegagalan database write tidak boleh meninggalkan orphan file secara permanen. Objek yang diganti pada akhirnya perlu dihapus. Log sebaiknya mencatat object identifier, actor, hasil, dan alasan penolakan tanpa menumpahkan private content atau nama yang tidak aman ke sebuah interface. Backup dan replica harus mempertahankan access expectation yang sama dengan primary storage. Endpoint download public mungkin memerlukan rate limit karena request kecil dapat memicu response besar.

Least privilege adalah batas terakhir, bukan pengganti validasi. PHP worker hanya boleh memiliki akses filesystem yang dibutuhkannya. Direktori upload tidak boleh berubah menjadi direktori script yang executable. Processing worker dapat dipisahkan dari aplikasi public jika format atau transformasinya memang membenarkan pemisahan itu. Kontrol-kontrol ini membatasi dampak ketika asumsi sebelumnya ternyata salah.

Checklist peninjauan

  • Fitur menetapkan user, format, limit, transformasi, visibility, dan retention secara spesifik.
  • Batas infrastruktur dan aplikasi menolak request yang terlalu besar atau tidak lengkap.
  • Kode server memeriksa UPLOAD_ERR_OK sebelum memproses file sementara.
  • Nama asli dan MIME type dari browser tidak pernah menentukan trust atau storage path.
  • Allowlist extension dan content type yang kecil mengikuti kebutuhan nyata fitur.
  • Aplikasi membuat opaque storage name dan menangani collision policy secara sengaja.
  • Upload berada di luar web root atau disajikan dari boundary yang tidak dapat mengeksekusinya.
  • Tindakan upload, replace, read, dan delete masing-masing memiliki authorization check.
  • CSRF, quota, parsing limit, cleanup, logging, backup, dan incident removal sudah tercakup.
  • Tim memahami hal-hal yang tidak dapat dipastikan oleh setiap pemeriksaan.

Kesimpulan

Pipeline upload yang aman tidak begitu mirip pemeriksaan lampiran di depan pintu, tetapi lebih menyerupai penjagaan rantai penguasaan atas paket yang tidak dikenal. Label bukanlah isi, lolos satu pemeriksaan tidak memberi izin untuk setiap penggunaan berikutnya, dan ruang penyimpanan seharusnya tidak merangkap sebagai lingkungan eksekusi.

Untuk aplikasi PHP kecil, langkah paling berharga biasanya adalah mempersempit kontrak: menerima lebih sedikit type, menerapkan limit eksplisit, membuat nama sendiri, menyimpan di luar public tree, dan memediasi akses. MIME detection, image rewriting, scanning, dan sandboxing dapat menambah layer jika threat model memang membutuhkannya. Tidak ada satu pun yang pantas disebut sebagai pengujian “safe file” universal. Tujuan yang jujur adalah penanganan terkendali dengan konsekuensi yang dibatasi ketika salah satu layer gagal.

References