Unggah Berkas

$_FILES, validasi, dan keamanan penyimpanan berkas.

Daftar isi

Unggahan berkas (file upload) memindahkan berkas dari mesin pengunjung ke server melalui form HTML, dan PHP menyerahkan hasilnya kepada skrip melalui array superglobal $_FILES. Topik ini membahas struktur $_FILES untuk unggahan tunggal dan ganda, lapisan validasi mulai dari kode error sampai isi berkas, serta penyimpanan yang aman, kemudian menutupnya dengan percobaan unggah nyata melalui HTTP. Setelah membacanya, skrip penerima unggahan dapat memilah berkas yang sah dari berkas yang harus ditolak sebelum berkas apa pun disimpan.

Struktur array $_FILES#

Setiap permintaan POST yang membawa berkas mengisi $_FILES, yaitu array asosiatif yang kuncinya nilai atribut name pada input berkas. Untuk input bernama lampiran, entri $_FILES['lampiran'] memuat lima kunci, yaitu name berisi nama asli dari mesin pengirim, type berisi jenis MIME yang diklaim browser, size berisi ukuran dalam byte, tmp_name berisi lokasi berkas sementara di server, dan error berisi kode status unggahan. Pengisian itu terjadi sebelum skrip berjalan, sehingga skrip cukup membaca array tanpa mengatur apa pun.

Pada unggahan ganda, atribut name ditulis sebagai lampiran[] sehingga setiap kunci berubah menjadi array sepanjang jumlah berkas yang dikirim, dan berkas diperiksa per indeks. Pengolahan kemudian dilakukan dalam perulangan yang membaca posisi yang sama dari kelima kunci. Antarmuka baris perintah tidak menerima unggahan, sehingga struktur pada percobaan berikut ditiru dengan pengisian manual.

php Struktur $_FILES untuk unggahan tunggal dan ganda
<?php
declare(strict_types=1);

// CLI tidak menerima unggahan, sehingga struktur $_FILES ditiru dengan pengisian manual
$_FILES['lampiran'] = [
    'name' => 'catatan.txt',
    'type' => 'text/plain',
    'size' => 27,
    'tmp_name' => '/tmp/phpA1b2C3',
    'error' => UPLOAD_ERR_OK,
];

echo 'Unggahan tunggal (input name="lampiran"):' . PHP_EOL;
print_r($_FILES);

// unggahan ganda (input name="lampiran[]") menjadikan setiap kunci sebuah array
$_FILES['lampiran'] = [
    'name' => ['catatan.txt', 'jadwal.png'],
    'type' => ['text/plain', 'image/png'],
    'size' => [27, 512],
    'tmp_name' => ['/tmp/phpD4e5F6', '/tmp/phpG7h8I9'],
    'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE],
];

echo 'Unggahan ganda, kunci name dan error berisi array:' . PHP_EOL;
print_r($_FILES['lampiran']['name']);
print_r($_FILES['lampiran']['error']);
text Keluaran
Unggahan tunggal (input name="lampiran"):
Array
(
    [lampiran] => Array
        (
            [name] => catatan.txt
            [type] => text/plain
            [size] => 27
            [tmp_name] => /tmp/phpA1b2C3
            [error] => 0
        )

)
Unggahan ganda, kunci name dan error berisi array:
Array
(
    [0] => catatan.txt
    [1] => jadwal.png
)
Array
(
    [0] => 0
    [1] => 4
)

Kunci error menentukan langkah berikutnya, karena berkas hanya layak diproses ketika nilainya UPLOAD_ERR_OK. Nilai lainnya menandakan kegagalan yang terjadi sebelum skrip sempat menyentuh berkas, dan arti setiap nilai dirangkum sebagai berikut. Pemeriksaannya cukup satu perbandingan ketat, karena konstanta bawaan PHP sudah mewakili setiap keadaan kegagalan tersebut.

KonstantaNilaiArti
UPLOAD_ERR_OK0Berkas diterima lengkap.
UPLOAD_ERR_INI_SIZE1Ukuran melebihi upload_max_filesize pada konfigurasi server.
UPLOAD_ERR_FORM_SIZE2Ukuran melebihi MAX_FILE_SIZE yang diatur pada form.
UPLOAD_ERR_PARTIAL3Berkas terunggah sebagian.
UPLOAD_ERR_NO_FILE4Tidak ada berkas yang dikirim.

Konstanta di atas tidak menggantikan pemeriksaan ukuran pada skrip. Ketika seluruh isi permintaan melebihi post_max_size, PHP mengosongkan $_FILES dan $_POST sekaligus, sehingga skrip tidak menerima kode error apa pun untuk diperiksa. Karena itu, batas ukuran tetap ditegakkan di dalam skrip sebagaimana dibahas pada bagian berikutnya.

Validasi berlapis#

Validasi unggahan berjalan sebagai lapisan yang diperiksa berurutan, yaitu kode error, batas ukuran, ekstensi, lalu isi berkas. Fungsi validasi pada berkas berikut mengumpulkan pesan penolakan dalam array sehingga satu pemeriksaan dapat menghasilkan beberapa temuan sekaligus, bukan berhenti pada temuan pertama. Urutan itu juga menghemat pekerjaan, karena berkas yang gagal pada lapisan awal tidak sampai pada pemeriksaan yang lebih berat.

php validasi.php
<?php
declare(strict_types=1);

const BATAS_UKURAN = 100000; // batas milik aplikasi sendiri, dalam byte
const MIME_EKSTENSI = [
    'txt' => 'text/plain',
    'pdf' => 'application/pdf',
    'png' => 'image/png',
    'jpg' => 'image/jpeg',
];

// memeriksa satu entri $_FILES dan mengembalikan daftar pesan penolakan
function validasi(array $berkas): array
{
    if (($berkas['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        return ['Unggahan gagal atau tidak ada berkas yang dikirim.'];
    }

    $pesan = [];

    if (($berkas['size'] ?? 0) > BATAS_UKURAN) {
        $pesan[] = 'Ukuran berkas melebihi batas ' . BATAS_UKURAN . ' byte.';
    }

    $ekstensi = strtolower(pathinfo($berkas['name'] ?? '', PATHINFO_EXTENSION));
    if (!array_key_exists($ekstensi, MIME_EKSTENSI)) {
        $pesan[] = 'Ekstensi .' . $ekstensi . ' tidak termasuk daftar yang diizinkan.';
    } else {
        // finfo membaca isi berkas, sedangkan type pada $_FILES hanya klaim dari browser
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $berkas['tmp_name']);
        finfo_close($finfo);

        if ($mime !== MIME_EKSTENSI[$ekstensi]) {
            $pesan[] = 'Isi berkas bertipe ' . $mime . ', tidak sesuai ekstensi .' . $ekstensi . '.';
        }
    }

    return $pesan;
}

Lapisan pertama memeriksa error terhadap UPLOAD_ERR_OK, karena tmp_name hanya layak disentuh ketika berkas diterima lengkap. Lapisan kedua membandingkan size dengan BATAS_UKURAN, yaitu konstanta milik aplikasi dan bukan upload_max_filesize milik server, karena kebutuhan setiap form tidak selalu sama dengan konfigurasi global. Lapisan ketiga memeriksa ekstensi terhadap peta MIME_EKSTENSI yang sekaligus menjadi daftar putih (whitelist), yaitu daftar nilai yang diizinkan, sehingga ekstensi di luar daftar ditolak tanpa pengecualian.

Lapisan terakhir mengabaikan type sama sekali. Nilai type pada $_FILES adalah klaim dari browser yang menyusun permintaan, dan klaim itu dapat diatur bebas oleh siapa pun yang mengirim permintaan, sehingga tidak pernah menjadi dasar keputusan. Fungsi finfo_open(FILEINFO_MIME_TYPE) beserta finfo_file membaca byte awal berkas untuk menentukan jenis isi yang sesungguhnya, lalu jenis itu dibandingkan dengan jenis yang diharapkan dari ekstensi berkas. Empat entri berikut menguji seluruh lapisan tersebut lewat CLI.

php uji-validasi.php
<?php
declare(strict_types=1);

require 'validasi.php';

// CLI tidak menerima unggahan, sehingga berkas uji dibuat dan struktur $_FILES ditiru
file_put_contents('/tmp/uji-catatan.txt', 'Rekap kegiatan ekstrakurikuler semester ini.' . PHP_EOL);
file_put_contents('/tmp/uji-foto.png', 'Teks biasa yang menyamar dengan ekstensi .png' . PHP_EOL);
file_put_contents('/tmp/uji-rekap.txt', str_repeat('a', 150000));

$uji = [
    'catatan' => [
        'name' => 'catatan.txt',
        'type' => 'text/plain',
        'size' => filesize('/tmp/uji-catatan.txt'),
        'tmp_name' => '/tmp/uji-catatan.txt',
        'error' => UPLOAD_ERR_OK,
    ],
    'foto' => [
        'name' => 'foto.png',
        'type' => 'image/png',
        'size' => filesize('/tmp/uji-foto.png'),
        'tmp_name' => '/tmp/uji-foto.png',
        'error' => UPLOAD_ERR_OK,
    ],
    'rekap' => [
        'name' => 'rekap.txt',
        'type' => 'text/plain',
        'size' => filesize('/tmp/uji-rekap.txt'),
        'tmp_name' => '/tmp/uji-rekap.txt',
        'error' => UPLOAD_ERR_OK,
    ],
    'potongan' => [
        'name' => 'tugas.txt',
        'type' => 'text/plain',
        'size' => 500,
        'tmp_name' => '/tmp/phpTidakAda',
        'error' => UPLOAD_ERR_PARTIAL,
    ],
];

foreach ($uji as $kunci => $berkas) {
    $pesan = validasi($berkas);
    echo $kunci . ' (' . $berkas['name'] . ', klaim ' . $berkas['type'] . '): ';
    echo $pesan === [] ? 'lolos validasi' : implode(' ', $pesan);
    echo PHP_EOL;
}

unlink('/tmp/uji-catatan.txt');
unlink('/tmp/uji-foto.png');
unlink('/tmp/uji-rekap.txt');
text Keluaran
catatan (catatan.txt, klaim text/plain): lolos validasi
foto (foto.png, klaim image/png): Isi berkas bertipe text/plain, tidak sesuai ekstensi .png.
rekap (rekap.txt, klaim text/plain): Ukuran berkas melebihi batas 100000 byte.
potongan (tugas.txt, klaim text/plain): Unggahan gagal atau tidak ada berkas yang dikirim.

Keluaran memperlihatkan empat hasil yang berbeda. Entri catatan lolos seluruh lapisan, entri potongan ditolak lebih dahulu karena kunci error menyatakan unggahan sebagian, entri rekap tertahan lapisan ukuran, dan entri foto lolos lapisan ekstensi tetapi tertangkap lapisan isi. Klaim image/png pada entri foto tidak digunakan sama sekali, sedangkan finfo membaca isi teks biasa, sehingga pesan penolakannya menyebut perbedaan antara isi dan ekstensi secara eksplisit.

Penyimpanan yang aman#

Berkas yang lolos validasi masih berada di folder sementara, sehingga penyimpanan permanen tetap merupakan tugas skrip. Fungsi move_uploaded_file memindahkan berkas sementara ke tujuan akhir dan menolak berkas yang bukan hasil unggahan asli, sedangkan is_uploaded_file dapat digunakan lebih dahulu bila pemeriksaan asal berkas perlu dilakukan secara terpisah. Skrip penerima unggahan lengkap disusun sebagai berikut.

php proses-unggah.php
<?php
declare(strict_types=1);

if (PHP_SAPI === 'cli') {
    echo 'Berkas ini dirancang untuk berjalan melalui server web.' . PHP_EOL;
    exit;
}

require 'validasi.php';

const FOLDER_SIMPAN = '/tmp/lampiran-unggahan';

$berkas = $_FILES['lampiran'] ?? [];
$pesan = validasi($berkas);

if ($pesan !== []) {
    http_response_code(422);
    echo implode(PHP_EOL, $pesan) . PHP_EOL;
    exit;
}

// hanya berkas yang benar-benar diunggah melalui HTTP yang boleh dipindahkan
if (!is_uploaded_file($berkas['tmp_name'])) {
    http_response_code(400);
    echo 'Sumber berkas bukan unggahan asli.' . PHP_EOL;
    exit;
}

if (!is_dir(FOLDER_SIMPAN)) {
    mkdir(FOLDER_SIMPAN, 0770, true);
}

// nama baru diturunkan dari isi berkas, bukan dari nama asli dari pengirim
$ekstensi = strtolower(pathinfo($berkas['name'], PATHINFO_EXTENSION));
$namaBaru = 'lampiran-' . substr(hash_file('sha256', $berkas['tmp_name']), 0, 16) . '.' . $ekstensi;

if (!move_uploaded_file($berkas['tmp_name'], FOLDER_SIMPAN . '/' . $namaBaru)) {
    http_response_code(500);
    echo 'Berkas tidak dapat disimpan ke folder tujuan.' . PHP_EOL;
    exit;
}

echo 'Berkas lolos validasi dan disimpan.' . PHP_EOL;
echo 'Nama asli : ' . $berkas['name'] . PHP_EOL;
echo 'Tersimpan : ' . FOLDER_SIMPAN . '/' . $namaBaru . PHP_EOL;
text Keluaran
Berkas ini dirancang untuk berjalan melalui server web.

Nama berkas baru diturunkan dari hash isi berkas, yaitu nilai yang dihitung dari isinya sehingga berkas yang sama selalu menghasilkan nama yang sama. Nama asli dari pengirim tidak digunakan karena dapat memuat karakter yang membingungkan sistem berkas dan dapat dipakai untuk menimpa berkas lain yang sudah ada di server. Turunan hash membuat percobaan dapat diulang dengan nama yang sama. Alternatif lain, bin2hex(random_bytes(8)), menghasilkan nama acak yang lebih sulit ditebak dan memenuhi tujuan yang sama, yaitu nama yang tidak berasal dari masukan pengguna.

Folder tujuan berada di /tmp, di luar akar dokumen server. Folder di luar akar dokumen tidak pernah dilayani sebagai URL, sehingga isi unggahan tidak dapat diminta langsung oleh siapa pun. Berkas unggahan yang berisi kode PHP juga tidak berpeluang dieksekusi karena tidak pernah tersimpan di akar dokumen. Akses kembali ke berkas yang sudah tersimpan dilakukan melalui skrip yang berhak membacanya.

Demo unggah melalui HTTP#

Form yang mengirim unggahan cukup berisi satu input berkas, tetapi atribut enctype wajib benar. Tanpa enctype="multipart/form-data", isian berkas dikirim sebagai teks nama berkas biasa dan $_FILES tetap kosong di sisi server. Atribut action menunjuk skrip pemrosesnya, sedangkan method="post" dipilih karena unggahan selalu dikirim dengan metode POST.

html Form unggahan dengan enctype multipart/form-data
<form action="proses-unggah.php" method="post" enctype="multipart/form-data">
  <label for="lampiran">Lampiran tugas</label>
  <input type="file" id="lampiran" name="lampiran">
  <button type="submit">Kirim lampiran</button>
</form>

Percobaan berikut menyiapkan folder tujuan dan berkas uji di /tmp, menjalankan server bawaan pada port 8120 dengan proses-unggah.php sebagai skrip router, lalu mengunggah berkas uji melalui field lampiran menggunakan curl. Server dibiarkan menyala untuk percobaan penolakan pada blok berikutnya. Seluruh artefak percobaan berada di /tmp sehingga folder topik tetap bersih.

bash
# siapkan folder tujuan di luar akar dokumen dan berkas uji di /tmp
mkdir -p /tmp/lampiran-unggahan
rm -f /tmp/lampiran-unggahan/*
printf 'Lampiran tugas: rekap kegiatan ekstrakurikuler.\n' > /tmp/lampiran-tugas.txt
# jalankan server bawaan pada port 8120 dengan proses-unggah.php sebagai router
php -S localhost:8120 proses-unggah.php >/dev/null 2>&1 &
sleep 1
# unggah berkas uji melalui field lampiran
curl -s -F "lampiran=@/tmp/lampiran-tugas.txt" http://localhost:8120/proses-unggah.php
# tunjukkan isi folder tujuan
ls /tmp/lampiran-unggahan
# tunjukkan isi berkas yang tersimpan
cat /tmp/lampiran-unggahan/lampiran-*.txt
text Keluaran
Berkas lolos validasi dan disimpan.
Nama asli : lampiran-tugas.txt
Tersimpan : /tmp/lampiran-unggahan/lampiran-7eada5c1e06b9f6d.txt
lampiran-7eada5c1e06b9f6d.txt
Lampiran tugas: rekap kegiatan ekstrakurikuler.

Keluaran menunjukkan tiga hal sekaligus, yaitu hasil validasi yang menyatakan berkas lolos, nama asli lampiran-tugas.txt yang hanya tampil sebagai keterangan, dan nama baru hasil hash yang menjadi nama berkas di folder tujuan. Isi berkas yang dibaca kembali dari folder tujuan sama persis dengan berkas uji, sehingga pemindahan tidak mengubah isinya. Nilai hash mengikuti isi berkas, sehingga berkas uji yang berbeda menghasilkan nama yang berbeda pula.

Unggahan yang tidak sah diuji dengan tiga berkas, yaitu program.exe yang ekstensinya terlarang, foto-palsu.png yang isinya teks biasa, dan rekap-besar.txt yang ukurannya 150000 byte. Ketiganya tertahan pada lapisan validasi yang berbeda, sehingga setiap penolakan dapat diamati satu per satu. Server yang sama masih menyala dari percobaan sebelumnya.

bash
# berkas dengan ekstensi terlarang ditolak lapisan ekstensi
printf 'MZ' > /tmp/program.exe
curl -s -F "lampiran=@/tmp/program.exe" http://localhost:8120/proses-unggah.php
# kode status HTTP dari penolakan
curl -s -o /dev/null -w "%{http_code}\n" -F "lampiran=@/tmp/program.exe" http://localhost:8120/proses-unggah.php
# berkas yang isinya teks tetapi berekstensi png ditolak lapisan isi
printf 'Teks biasa yang menyamar sebagai gambar.\n' > /tmp/foto-palsu.png
curl -s -F "lampiran=@/tmp/foto-palsu.png" http://localhost:8120/proses-unggah.php
# berkas yang melebihi batas ukuran aplikasi ditolak lapisan ukuran
head -c 150000 /dev/zero | tr '\0' 'a' > /tmp/rekap-besar.txt
curl -s -F "lampiran=@/tmp/rekap-besar.txt" http://localhost:8120/proses-unggah.php
# folder tujuan tidak bertambah
ls /tmp/lampiran-unggahan
# hentikan server bawaan
kill %1
text Keluaran
Ekstensi .exe tidak termasuk daftar yang diizinkan.
422
Isi berkas bertipe text/plain, tidak sesuai ekstensi .png.
Ukuran berkas melebihi batas 100000 byte.
lampiran-7eada5c1e06b9f6d.txt

Setiap penolakan datang dari lapisan yang berbeda, yaitu lapisan ekstensi untuk program.exe, lapisan isi untuk foto-palsu.png, dan lapisan ukuran untuk rekap-besar.txt. Skrip menjawab dengan kode status 422, dan folder tujuan tetap hanya berisi satu berkas dari percobaan sah sebelumnya, sehingga tidak ada berkas tidak sah yang sempat tersimpan. Pesan tiap penolakan menyebut penyebabnya secara spesifik, sehingga pengirim dapat memperbaiki unggahan tanpa menebak.

Kesalahan umum#

Empat kesalahan berikut paling sering muncul saat menangani unggahan pertama kali.

  • Kunci name dan type dari $_FILES dipercaya apa adanya. Nama asli dapat memuat karakter yang membingungkan sistem berkas, sedangkan type hanya klaim dari browser yang dapat diatur bebas. Perbaikannya adalah memvalidasi isi dengan finfo dan menyimpan berkas dengan nama baru yang diturunkan aplikasi.
  • Kunci error tidak diperiksa sebelum tmp_name disentuh. Ketika unggahan gagal, tmp_name dapat berupa string kosong sehingga pemeriksaan berikutnya memicu warning. Perbaikannya adalah menguji error === UPLOAD_ERR_OK terlebih dahulu dan berhenti di situ bila nilainya berbeda.
  • Unggahan disimpan dengan nama asli di dalam akar dokumen. Berkas yang berisi kode PHP dan tampak seperti gambar dapat dipanggil sebagai URL lalu dieksekusi server. Perbaikannya adalah memindahkan berkas dengan nama baru ke folder di luar akar dokumen.
  • Atribut enctype="multipart/form-data" tertinggal pada form. Isian berkas dikirim sebagai teks biasa, $_FILES tetap kosong, dan skrip selalu menjawab tidak adanya berkas. Perbaikannya adalah memeriksa atribut form terlebih dahulu sebelum mencari penyebab di skrip PHP.

Ringkasan#

  • $_FILES menyerahkan setiap unggahan dengan kunci name, type, size, tmp_name, dan error, dan pada unggahan ganda setiap kunci menjadi array yang diperiksa per indeks.
  • Validasi berlapis menguji kode error, batas ukuran milik aplikasi, ekstensi terhadap daftar putih, lalu isi berkas dengan finfo, karena type pada $_FILES hanya klaim dari browser.
  • move_uploaded_file memindahkan berkas sementara ke folder di luar akar dokumen dengan nama baru dari hash isi berkas, bukan dari nama asli pengirim.
  • Percobaan melalui HTTP menunjukkan berkas sah tersimpan dengan nama baru, sedangkan ekstensi terlarang, isi yang menyamar, dan ukuran berlebih ditolak dengan status 422 beserta pesan per lapisan.

Referensi#