Fitur upload file sering terlihat sederhana, tetapi justru menjadi salah satu titik masuk serangan yang paling sering diabaikan. Di CodeIgniter 4, upload file yang aman tidak cukup hanya memeriksa ekstensi atau memanggil move(). Anda perlu validasi berlapis: validasi request, verifikasi MIME di server, pembatasan ukuran, penyimpanan di lokasi terisolasi, dan pola akses file melalui controller agar file tidak bisa dieksekusi atau diakses langsung.
Panduan ini fokus pada ancaman yang paling umum: spoofed MIME, ekstensi ganda seperti shell.php.jpg, path traversal, file terlalu besar, malware sederhana, dan risiko eksekusi file di direktori publik. Contoh di bawah memakai pendekatan yang realistis untuk aplikasi CodeIgniter 4 tanpa bergantung pada asumsi yang terlalu spesifik versi.
Mengapa upload file berbahaya jika hanya divalidasi di form
Validasi di sisi form atau browser hanya membantu pengalaman pengguna, bukan keamanan. Penyerang bisa mengirim request langsung ke endpoint upload dengan curl, Postman, Burp Suite, atau skrip otomatis. Karena itu, semua pemeriksaan penting harus dilakukan lagi di server.
Beberapa ancaman yang perlu ditangani:
- Spoofed MIME: header
Content-Typedari klien dapat dipalsukan. - Ekstensi ganda: misalnya
invoice.pdf.phpatauavatar.jpg.php. - Path traversal: nama file berisi pola seperti
../../untuk mencoba keluar dari direktori target. - File terlalu besar: bisa menghabiskan disk, memori, atau bandwidth.
- Malware sederhana: misalnya skrip PHP, JS berbahaya, atau file makro yang lolos tanpa pemeriksaan dasar.
- Eksekusi di direktori publik: file yang diunggah ke dalam web root bisa diakses langsung, bahkan dieksekusi jika server salah konfigurasi.
Tujuan desain yang aman adalah: hanya menerima tipe file yang dibutuhkan, menyimpan file dengan nama acak, menaruhnya di luar public root, dan menyajikannya kembali lewat controller dengan otorisasi yang jelas.
Prinsip desain upload file aman di CodeIgniter 4
1. Gunakan whitelist, bukan blacklist
Jangan mencoba memblokir semua jenis file berbahaya satu per satu. Pendekatan yang lebih aman adalah hanya menerima tipe file yang memang dibutuhkan aplikasi, misalnya PDF dan gambar tertentu.
2. Validasi berlapis
Minimal ada beberapa lapisan pemeriksaan:
- Validasi field upload pada request.
- Pengecekan apakah file benar-benar terunggah.
- Pemeriksaan ukuran file.
- Pemeriksaan ekstensi yang diizinkan.
- Pemeriksaan MIME berdasarkan isi file, bukan hanya header request.
- Rename file menjadi nama acak.
- Simpan file di direktori non-publik.
3. Jangan percaya nama file dari pengguna
Nama asli file bisa mengandung karakter aneh, pola traversal, atau informasi yang tidak ingin Anda simpan. Nama file dari pengguna sebaiknya hanya dipakai sebagai metadata, bukan sebagai nama file fisik di storage.
4. Pisahkan storage dari akses HTTP langsung
File hasil upload sebaiknya disimpan di luar direktori publik, lalu diunduh melalui endpoint controller. Dengan pola ini, Anda bisa menerapkan otorisasi, audit, rate limit, dan kontrol header response dengan lebih aman.
Struktur penyimpanan yang disarankan
Salah satu pola yang umum adalah membuat direktori storage upload di luar web root. Misalnya:
/var/www/app-root/
app/
public/
writable/
storage/uploads/Jika lingkungan deployment Anda tidak memungkinkan direktori di luar project root, pilih setidaknya lokasi yang tidak dipetakan sebagai document root web server. Hindari menyimpan file upload pengguna di bawah public/.
Di CodeIgniter 4, Anda bisa mendefinisikan path ini dalam konfigurasi aplikasi sendiri. Contohnya, simpan nilai path dalam file konfigurasi khusus agar tidak tersebar di controller.
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Uploads extends BaseConfig
{
public string $storagePath = '/var/www/app-root/storage/uploads';
public int $maxSizeKb = 2048;
public array $allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png'];
public array $allowedMimeTypes = [
'application/pdf',
'image/jpeg',
'image/png',
];
}Keuntungan pendekatan ini:
- Path storage tersentralisasi.
- Batas ukuran dan whitelist mudah diubah.
- Controller tetap fokus pada alur upload, bukan detail konfigurasi.
Validasi request di CodeIgniter 4
Lapisan pertama adalah validasi request. Ini berguna untuk menolak request yang jelas tidak sesuai sebelum file diproses lebih jauh. Walaupun aturan validasi membantu, anggap ini sebagai filter awal, bukan pemeriksaan final.
<?php
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Uploads;
class DocumentController extends BaseController
{
public function upload(): ResponseInterface
{
$config = config(Uploads::class);
$rules = [
'document' => [
'label' => 'Dokumen',
'rules' => [
'uploaded[document]',
'max_size[document,' . $config->maxSizeKb . ']',
'ext_in[document,' . implode(',', $config->allowedExtensions) . ']',
'mime_in[document,' . implode(',', $config->allowedMimeTypes) . ']',
],
],
];
if (! $this->validate($rules)) {
log_message('warning', 'Upload ditolak oleh validasi request: {errors}', [
'errors' => json_encode($this->validator->getErrors()),
]);
return $this->response->setStatusCode(422)->setJSON([
'status' => 'error',
'errors' => $this->validator->getErrors(),
]);
}
$file = $this->request->getFile('document');
if (! $file || ! $file->isValid()) {
log_message('warning', 'Upload gagal: file tidak valid atau tidak lengkap');
return $this->response->setStatusCode(400)->setJSON([
'status' => 'error',
'message' => 'File upload tidak valid.',
]);
}
if ($file->hasMoved()) {
log_message('warning', 'Upload ditolak: file sudah dipindahkan sebelumnya');
return $this->response->setStatusCode(400)->setJSON([
'status' => 'error',
'message' => 'File sudah diproses sebelumnya.',
]);
}
return $this->storeUploadedFile($file, $config);
}Aturan seperti ext_in, mime_in, dan max_size adalah awal yang baik. Namun, jangan berhenti di sini karena masih ada kemungkinan bypass, terutama jika hanya bergantung pada metadata file yang datang dari request.
Verifikasi MIME dengan finfo dan sanitasi metadata file
Pemeriksaan MIME yang lebih kuat sebaiknya dilakukan di server berdasarkan isi file. Di PHP, pendekatan umum adalah menggunakan finfo untuk membaca signature file. Ini membantu mendeteksi file yang menyamar dengan ekstensi atau Content-Type palsu.
protected function storeUploadedFile($file, Uploads $config): ResponseInterface
{
$tmpPath = $file->getTempName();
$detectedMime = null;
if (is_file($tmpPath)) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
$detectedMime = finfo_file($finfo, $tmpPath);
finfo_close($finfo);
}
}
if (! $detectedMime || ! in_array($detectedMime, $config->allowedMimeTypes, true)) {
log_message('warning', 'Upload ditolak: MIME tidak diizinkan. MIME terdeteksi: {mime}', [
'mime' => $detectedMime ?? 'unknown',
]);
return $this->response->setStatusCode(415)->setJSON([
'status' => 'error',
'message' => 'Tipe file tidak diizinkan.',
]);
}
$originalName = $file->getClientName();
$originalExt = strtolower($file->getClientExtension() ?? '');
if (! in_array($originalExt, $config->allowedExtensions, true)) {
log_message('warning', 'Upload ditolak: ekstensi tidak diizinkan. Nama file: {name}', [
'name' => $originalName,
]);
return $this->response->setStatusCode(415)->setJSON([
'status' => 'error',
'message' => 'Ekstensi file tidak diizinkan.',
]);
}
$safeName = bin2hex(random_bytes(16)) . '.' . $originalExt;
$targetDir = rtrim($config->storagePath, DIRECTORY_SEPARATOR);
if (! is_dir($targetDir) && ! mkdir($targetDir, 0750, true)) {
log_message('error', 'Gagal membuat direktori upload: {dir}', ['dir' => $targetDir]);
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => 'Gagal menyiapkan storage upload.',
]);
}
$file->move($targetDir, $safeName);
return $this->response->setJSON([
'status' => 'ok',
'message' => 'File berhasil diupload.',
'data' => [
'stored_name' => $safeName,
'original_name' => $originalName,
'mime' => $detectedMime,
'size' => $file->getSize(),
],
]);
}
}Mengapa pemeriksaan ini penting:
getClientName()berasal dari klien, jadi tidak boleh dipercaya sebagai nama simpan.getClientExtension()membantu membaca ekstensi yang dikirim klien, tetapi tetap harus dicocokkan dengan whitelist dan MIME aktual.finfomemeriksa isi file, bukan hanya deklarasi dari request.- Nama acak mencegah collision, menyulitkan enumerasi file, dan menghilangkan risiko karakter berbahaya di nama file.
Catatan: MIME detection tidak selalu sempurna untuk semua format. Karena itu, gunakan kombinasi whitelist ekstensi, MIME terdeteksi, batas ukuran, dan isolasi storage. Jangan mengandalkan satu pemeriksaan saja.
Mengatasi ancaman umum satu per satu
Spoofed MIME
Jika penyerang mengirim file PHP tetapi memberi header Content-Type: image/jpeg, validasi berbasis request saja bisa tertipu. Solusinya adalah verifikasi isi file dengan finfo di server.
Ekstensi ganda
File seperti avatar.jpg.php sering dipakai untuk mencoba lolos dari pemeriksaan yang hanya melihat bagian awal nama file. Solusinya:
- Jangan gunakan nama asli file sebagai nama simpan.
- Ambil ekstensi akhir dan cocokkan dengan whitelist.
- Pastikan MIME aktual sesuai dengan tipe yang diizinkan.
Dengan rename ke nama acak seperti 4f1c...d9a2.pdf, risiko dari pola nama file berbahaya turun drastis.
Path traversal
Serangan traversal biasanya mencoba menyisipkan path seperti ../../../../tmp/shell.php. Jika Anda tidak pernah memakai nama file dari pengguna sebagai path final, risiko ini jauh lebih kecil. Hindari membuat path seperti:
$file->move($targetDir, $file->getClientName());Lebih aman memakai nama acak yang Anda hasilkan sendiri.
File terlalu besar
Batasi ukuran di validasi aplikasi dan pastikan konfigurasi PHP serta web server sejalan. Jika aplikasi mengizinkan 2 MB tetapi server hanya mengizinkan lebih kecil, upload akan gagal sebelum sampai ke logika aplikasi. Sebaliknya, jika server terlalu longgar, penyerang masih bisa mencoba mengirim file besar untuk membebani resource.
Yang perlu diselaraskan:
- Batas ukuran di rule validasi.
- Batas upload di konfigurasi PHP.
- Batas request body di web server atau reverse proxy, jika ada.
Malware sederhana
Aplikasi web biasa jarang bisa melakukan antivirus penuh tanpa integrasi eksternal. Namun, Anda tetap bisa menurunkan risiko dengan langkah dasar:
- Whitelist tipe file secara ketat.
- Tolak file executable, skrip, arsip, atau format yang tidak dibutuhkan.
- Simpan di luar public root.
- Tambahkan proses scanning eksternal jika sistem Anda memang menuntut itu, misalnya lewat job asynchronous atau layanan terpisah.
Jika Anda menerima dokumen dari pengguna umum dalam skala besar, pertimbangkan desain karantina: file disimpan dulu sebagai pending, dipindai oleh proses terpisah, lalu baru ditandai aman untuk diunduh.
Eksekusi file di direktori publik
Ini salah satu kesalahan paling mahal. Jika file upload disimpan di bawah public/ dan server mengizinkan eksekusi skrip pada path tersebut, file berbahaya bisa dijalankan langsung lewat URL. Solusi paling aman adalah menyimpan file di luar document root dan melayani download melalui controller.
Pola download via controller agar file tidak diakses langsung
Setelah file disimpan di storage terisolasi, jangan membuat URL langsung ke file fisik. Simpan metadata file di database, misalnya:
- ID file
- nama asli
- nama simpan acak
- MIME
- ukuran
- pemilik file
- waktu upload
Lalu buat endpoint download yang melakukan:
- Autentikasi pengguna.
- Otorisasi apakah pengguna berhak mengakses file tersebut.
- Validasi bahwa file ada di storage yang benar.
- Pengiriman response download dengan header yang sesuai.
<?php
namespace App\Controllers;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Uploads;
class FileController extends BaseController
{
public function download(string $storedName)
{
$config = config(Uploads::class);
// Contoh sederhana: sebaiknya storedName diambil dari DB berdasarkan ID,
// bukan langsung dari parameter URL.
if (! preg_match('/^[a-f0-9]{32}\.(pdf|jpg|jpeg|png)$/', $storedName)) {
throw PageNotFoundException::forPageNotFound();
}
$fullPath = rtrim($config->storagePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $storedName;
$realBase = realpath($config->storagePath);
$realFile = realpath($fullPath);
if (! $realBase || ! $realFile || strpos($realFile, $realBase) !== 0 || ! is_file($realFile)) {
throw PageNotFoundException::forPageNotFound();
}
// Tambahkan pemeriksaan otorisasi di sini sebelum download.
return $this->response->download($realFile, null);
}
}Pola ini membantu mencegah akses langsung, traversal, dan kebocoran file melalui URL yang dapat ditebak. Dalam implementasi nyata, lebih baik endpoint menerima ID file, lalu aplikasi mengambil nama file acak dari database setelah otorisasi lolos.
Permission direktori dan file
Permission yang terlalu longgar memperbesar dampak jika ada bug lain di aplikasi atau server. Prinsip umumnya:
- Direktori upload hanya perlu dapat ditulis oleh user proses aplikasi.
- Hindari permission seperti
0777. - Gunakan permission direktori yang cukup ketat, misalnya pola seperti
0750jika sesuai dengan lingkungan Anda. - Pastikan owner dan group benar agar aplikasi tidak gagal menulis lalu developer tergoda membuka permission terlalu lebar.
Trade-off di sini adalah kompatibilitas deployment. Di beberapa environment, detail owner/group berbeda. Yang penting, jangan menjadikan permission longgar sebagai solusi cepat permanen.
Logging upload gagal dan audit keamanan
Upload yang ditolak sebaiknya dicatat, terutama jika alasannya terkait validasi MIME, ekstensi, ukuran, atau file rusak. Logging membantu Anda membedakan antara bug aplikasi dan upaya bypass.
Informasi yang berguna untuk dicatat:
- Waktu kejadian
- ID pengguna atau identitas sesi jika tersedia
- Alamat IP atau metadata request sesuai kebijakan privasi Anda
- Nama file asli
- MIME yang diklaim klien
- MIME hasil deteksi server
- Alasan penolakan
Hindari mencatat isi file atau data sensitif secara berlebihan. Tujuan log adalah investigasi, bukan menyimpan payload berbahaya di sistem log.
log_message('warning', 'Upload ditolak', [
'user_id' => auth()->id() ?? null,
'original_name' => $file->getClientName(),
'client_mime' => $file->getClientMimeType(),
'reason' => 'mime_not_allowed',
]);Jika helper autentikasi Anda berbeda, sesuaikan cara mengambil identitas user. Intinya, buat log cukup informatif untuk audit dan debugging.
Kesalahan umum yang sering lolos saat code review
- Menyimpan file upload di bawah
public/karena dianggap lebih mudah diakses. - Memakai nama asli file untuk penyimpanan, sehingga rawan collision, traversal, dan karakter bermasalah.
- Hanya memeriksa ekstensi tanpa verifikasi MIME dari isi file.
- Hanya memeriksa MIME tanpa whitelist ekstensi yang ketat.
- Tidak membatasi ukuran file di aplikasi dan server secara konsisten.
- Tidak memeriksa
isValid()atauhasMoved()sebelum memindahkan file. - Mengembalikan path fisik storage ke klien, yang membocorkan struktur server.
- Tidak ada otorisasi pada endpoint download.
- Tidak ada logging untuk upload yang ditolak, sehingga serangan probing terlihat seperti error acak.
- Membuka permission direktori terlalu lebar untuk mengatasi masalah deployment sementara.
Checklist hardening upload file aman di CodeIgniter 4
- Hanya izinkan tipe file yang benar-benar dibutuhkan.
- Gunakan validasi request:
uploaded,max_size,ext_in, danmime_in. - Verifikasi MIME lagi di server dengan
finfo. - Rename file dengan nama acak yang dibuat server.
- Simpan file di luar public root.
- Jangan gunakan nama file dari pengguna sebagai path penyimpanan.
- Batasi permission direktori dan file.
- Sinkronkan batas ukuran di aplikasi, PHP, dan web server.
- Sajikan file lewat controller, bukan URL langsung ke file fisik.
- Terapkan autentikasi dan otorisasi pada download.
- Catat upload gagal dan alasan penolakannya.
- Pertimbangkan scanning tambahan untuk use case berisiko tinggi.
Tips pengujian manual untuk mencoba bypass validasi
Sebelum fitur dianggap aman, uji secara manual beberapa skenario bypass berikut:
1. Ubah ekstensi file
Ambil file teks atau skrip sederhana, lalu ganti menjadi .jpg atau .pdf. Pastikan validasi MIME berbasis isi file tetap menolak.
2. Gunakan ekstensi ganda
Coba upload file bernama:
test.jpg.phpinvoice.pdf.phpavatar.png.phtml
Pastikan aplikasi menolak, dan nama file asli tidak pernah dipakai sebagai nama simpan.
3. Palsukan Content-Type
Kirim request menggunakan tool seperti curl atau Postman dan set header multipart seolah file adalah image/jpeg, padahal isinya bukan gambar. Server seharusnya tetap menolak setelah pemeriksaan finfo.
curl -X POST https://example.com/documents/upload \
-F "document=@./payload.php;type=image/jpeg"4. Coba nama file traversal
Gunakan nama file seperti ../../shell.php atau karakter aneh lainnya. Jika aplikasi selalu membuat nama acak sendiri, percobaan ini seharusnya tidak berpengaruh.
5. Uji file melebihi batas ukuran
Pastikan aplikasi memberikan error yang jelas, dan log mencatat penolakan. Jika request gagal sebelum mencapai aplikasi, periksa konfigurasi PHP atau web server.
6. Uji akses langsung ke file
Pastikan file hasil upload tidak dapat diakses lewat URL publik. Jika masih bisa dibuka langsung, berarti storage belum benar-benar terisolasi.
7. Uji otorisasi endpoint download
Coba akses file milik user lain atau file tanpa login. Endpoint download harus menolak akses sebelum file dikirim.
Penutup
Upload file aman di CodeIgniter 4 membutuhkan kombinasi beberapa kontrol, bukan satu rule validasi saja. Pendekatan yang paling masuk akal untuk banyak aplikasi adalah: validasi request, verifikasi MIME dengan finfo, whitelist ekstensi, nama file acak, penyimpanan di luar public root, permission ketat, logging upload gagal, dan download melalui controller.
Jika Anda sedang melakukan review code, dua pertanyaan terpenting adalah: apakah file bisa diakses atau dieksekusi langsung? dan apakah aplikasi memverifikasi isi file, bukan hanya metadata dari klien? Jika dua hal ini sudah ditangani dengan benar, risiko utama pada fitur upload biasanya turun secara signifikan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!