Di filesystem, string adalah segalanya: satu string path menentukan berkas mana yang dibaca, ditimpa, atau dihapus. Begitu sebagian string itu berasal dari masukan pengguna, pihak yang mengetikkan input memegang kendali atas operasi berkas yang kita jalankan — dan penyerang selalu mengetik lebih kreatif dari yang kita duga. Chapter ini membahas ancaman filesystem yang paling sering muncul di aplikasi PHP beserta pertahanannya: path traversal, injeksi perintah lewat nama berkas, penyimpanan upload yang aman, permission, dan race condition. Semua contoh rentan di sini hanya untuk pembelajaran dan selalu diikuti versi amannya.
Bahaya Path Traversal #
Path traversal adalah serangan di mana masukan berisi ../ untuk memanjat keluar dari direktori yang seharusnya membatasi akses. Pola rentannya hampir selalu berbentuk penyambungan string mentah ke path dasar:
<?php
// RENTAN — contoh pembelajaran, jangan ditiru di kode nyata!
$namaFile = $_GET['file'] ?? 'laporan.txt';
// Input "../../../etc/passwd" lolos langsung sampai ke disk
$isi = file_get_contents('/var/www/app/data/' . $namaFile);Kode di atas bermaksud melayani berkas di dalam /var/www/app/data/, tetapi system call yang dijalankan justru file_get_contents('/var/www/app/data/../../../etc/passwd'). Sistem operasi menormalkan ../ saat menelusuri path, sehingga hasil akhirnya adalah /etc/passwd — berkas di luar area yang diizinkan, dibaca dan dikirim ke penyerang. Varian lain memakai %2e%2e%2f (encoding titik-titik) atau path absolut seperti /etc/passwd yang langsung menimpa path dasar.
Dampaknya bergantung pada operasi yang dilindungi: file_get_contents() membocorkan isi berkas rahasia, file_put_contents() menimpa berkas konfigurasi, dan unlink() menghapus berkas secara permanen. Prinsip pertahanannya satu: jangan pernah memasukkan masukan mentah ke dalam path — normalisasi dulu, lalu periksa hasilnya sebelum dipakai.
Pertahanan dengan basename #
Saat yang kita butuhkan hanyalah nama berkas — bukan path — basename() memangkas seluruh komponen direktori dari masukan:
<?php
$input = '../../../etc/passwd';
echo 'basename : ' . basename($input) . "\n";Output yang Dihasilkan:
basename : passwdHasil passwd sudah tidak mengandung satu pun ../, sehingga aman disambungkan ke path dasar tanpa risiko memanjat. Meski aman dari traversal, basename() sendiri belum cukup: hasilnya tetap boleh berupa nama berkas apa pun, termasuk config.php milik aplikasi. Karena itu, gabungkan basename() dengan daftar izin — misalnya memvalidasi nama hasil terhadap daftar berkas yang memang boleh diunduh, atau memakai ctype_digit() bila masukan seharusnya berupa angka.
Pertahanan dengan realpath #
Untuk pertahanan yang menyeluruh, PHP menyediakan realpath(): fungsi ini menormalkan seluruh ../ dan ., sekaligus mengikuti symlink, lalu mengembalikan path absolut hasilnya — atau false bila berkasnya tidak ada. Dikombinasikan dengan pemeriksaan containment (apakah hasil tetap berada di dalam direktori dasar), pola ini menjadi gerbang standar sebelum membuka berkas:
<?php
$baseDir = sys_get_temp_dir() . '/kursus-php-upload-budi';
@mkdir($baseDir);
file_put_contents($baseDir . '/laporan.txt', 'data laporan');
function resolveAman(string $baseDir, string $masukan): string|false
{
$path = realpath($baseDir . '/' . $masukan);
if ($path === false) {
return false;
}
if (!str_starts_with($path, rtrim($baseDir, '/') . '/')) {
return false;
}
return $path;
}
foreach (['laporan.txt', '../../../etc/passwd', 'tidak-ada.txt'] as $masukan) {
$hasil = resolveAman($baseDir, $masukan);
echo str_pad($masukan, 24) . ' => ' . ($hasil === false ? 'DITOLAK' : 'DIIZINKAN') . "\n";
}
unlink($baseDir . '/laporan.txt');
rmdir($baseDir);Output yang Dihasilkan:
laporan.txt => DIIZINKAN
../../../etc/passwd => DITOLAK
tidak-ada.txt => DITOLAKMari kita bedah pertahanannya baris per baris:
realpath($baseDir . '/' . $masukan)menyelesaikan seluruh trik path menjadi path absolut final. Masukan../../../etc/passwdmenghasilkan/etc/passwd— triknya sudah tidak relevan, yang tersisa hanyalah pertanyaan: apakah path final itu masih di dalam$baseDir?- Pemeriksaan
=== falsemenolak masukan yang menunjuk ke berkas yang tidak ada, sekaligus menutup celah “berkas akan dibuat nanti”. str_starts_with($path, rtrim($baseDir, '/') . '/')adalah inti containment: path absolut harus diawali direktori dasar diikuti/. Karakter/di ujung awalan ini penting — tanpanya, direktori saudara seperti/uploads-rahasiaakan lolos karena memang diawali string/uploads.- Karena
realpath()juga mengikuti symlink, tautan di dalam$baseDiryang menunjuk ke luar akan terungkap sebagai path final di luar dasar, lalu ditolak.
Fungsi resolveAman() dalam contoh sengaja mengembalikan string|false sehingga pemanggilnya terpaksa menangani kasus penolakan. Perlihatkan di output: hanya laporan.txt yang diizinkan, sementara upaya traversal dan nama berkas yang tidak ada ditolak mentah-mentah.
Membatasi Akses dengan open_basedir #
Validasi di level kode bisa luput dari satu titik, sehingga PHP menyediakan lapisan kedua di level konfigurasi: open_basedir. Direktif ini membatasi seluruh operasi file PHP hanya pada direktori yang tercantum — upaya akses di luar itu memicu warning dan mengembalikan false:
<?php
// Jalankan dengan pembatasan:
// php -d open_basedir=/var/www/app contoh-basedir.php
var_dump(is_file('/etc/passwd'));Saat kami mengujinya dengan php -d open_basedir=/var/www/app, pemanggilan is_file('/etc/passwd') menghasilkan warning open_basedir restriction in effect beserta bool(false) — PHP menolak bahkan sekadar memeriksa keberadaan berkas di luar batas. Pengaturan ini biasanya diterapkan per-vhost di konfigurasi server atau pool FPM, satu set batasan untuk satu aplikasi.
Injeksi Perintah lewat Nama Berkas #
Ketika nama berkas disisipkan ke dalam perintah shell, penyerang tidak lagi butuh ../ — cukup karakter pemisah perintah. Pola rentannya:
<?php
// RENTAN — contoh pembelajaran, jangan ditiru di kode nyata!
$namaFile = $_GET['nama'] ?? 'laporan.txt';
// Input "laporan; cat /etc/passwd" dieksekusi sebagai dua perintah
exec("wc -c /var/www/app/data/{$namaFile}");Shell menafsirkan titik koma sebagai pemisah, jadi selain menghitung ukuran berkas, server juga menjalankan perintah susulan yang dibawa penyerang — dan cat /etc/passwd di sini hanya contoh yang paling jinak. Versi amannya membungkus argumen dengan escapeshellarg(), yang mengurung nilai dalam kutip tunggal sekaligus meng-escape kutip di dalamnya:
<?php
$namaFile = 'laporan; cat /etc/passwd';
$argAman = escapeshellarg($namaFile);
echo 'wc -c ' . $argAman . "\n";Output yang Dihasilkan:
wc -c 'laporan; cat /etc/passwd'Output memperlihatkan hasil pengurungan: wc -c 'laporan; cat /etc/passwd'. Bagi shell, seluruh string di dalam kutip kini hanyalah satu argumen tunggal milik wc — titik koma kehilangan kekuatannya. Meski begitu, aturan terbaiknya lebih sederhana lagi: bila PHP punya fungsi asli untuk pekerjaan itu (filesize() untuk contoh di atas), jangan memanggil shell sama sekali. Resiko injeksi hilang total ketika tidak ada shell yang terlibat.
Penyimpanan Upload yang Aman #
Nama berkas hasil upload adalah masukan pengguna paling berbahaya karena tampilnya bisa menipu: 'foto.jpg' bisa dikirim sebagai 'foto.jpg.php', '../logo.png', bahkan "laporan\x00.jpg" dengan byte kosong. Prinsip pertamanya tegas — nama asli dari klien tidak pernah dipakai sebagai nama berkas di penyimpanan:
<?php
function namaPenyimpananAman(string $namaAsli, int $idPengguna, array $ekstensiDiizinkan): string|false
{
$ekstensi = strtolower(pathinfo($namaAsli, PATHINFO_EXTENSION));
if (!in_array($ekstensi, $ekstensiDiizinkan, true)) {
return false;
}
return sprintf('unggahan-%d.%s', $idPengguna, $ekstensi);
}
foreach (['foto-liburan.jpg', 'shell.php', 'laporan.txt'] as $nama) {
$hasil = namaPenyimpananAman($nama, 42, ['jpg', 'png', 'webp']);
echo str_pad($nama, 20) . ' => ' . ($hasil ?: 'DITOLAK') . "\n";
}Output yang Dihasilkan:
foto-liburan.jpg => unggahan-42.jpg
shell.php => DITOLAK
laporan.txt => DITOLAKFungsi di atas mengambil ekstensi dengan pathinfo() — yang bekerja pada string sehingga ../logo.png tetap menghasilkan png — membandingkannya secara ketat dengan daftar izin, lalu membangun nama baru sepenuhnya dari data yang kita kendalikan: identitas pengguna dan ekstensi yang lolos seleksi. Hasil shell.php dan laporan.txt ditolak karena ekstensinya tidak termasuk jpg, png, atau webp. Pada aplikasi nyata, tambahkan unsur acak dari random_bytes() agar dua upload tidak saling menimpa, dan simpan pemetaan nama asli ke nama penyimpanan di database bila nama asli perlu ditampilkan kembali.
Dua alat bantu tambahan untuk upload ada di chapter uploads dan file storage
: is_uploaded_file() yang memastikan berkas benar-benar berasal dari upload, dan move_uploaded_file() yang menjadi satu-satunya cara pemindahan yang aman dari posisi sementaranya.
Permission dan Prinsip Hak Minimal #
Lapisan terakhir pertahanan filesystem adalah permission milik sistem operasi. Prinsipnya disebut least privilege: setiap proses hanya boleh memegang hak akses minimal yang dibutuhkan pekerjaannya — proses web milik pengguna www-data tidak perlu memiliki berkas, cukup membaca yang perlu dan menulis hanya pada direktori yang memang dituju. PHP mengatur permission lewat chmod():
<?php
$file = sys_get_temp_dir() . '/kursus-php-perm-demo.txt';
file_put_contents($file, 'data rahasia');
chmod($file, 0640);
echo 'Permission: ' . substr(sprintf('%o', fileperms($file)), -4) . "\n";
unlink($file);Output yang Dihasilkan:
Permission: 0640Mode 0640 memberi pemilik hak baca-tulis, group hak baca, dan dunia luar tidak ada hak sama sekali — pilihan yang wajar untuk berkas data aplikasi. fileperms() mengembalikan mode lengkap beserta bit tipe berkas, sehingga sprintf('%o', ...) lalu substr(..., -4) memotongnya menjadi empat digit mode yang lazim kita kenal. Untuk direktori, patokannya 0750: pemilik bebas, group bisa masuk dan membaca, dunia luar tertutup. Nilai 0777 — dunia boleh menulis — tidak pernah menjadi jawaban yang benar; kalau sebuah proses “butuh” 0777, masalah sebenarnya ada pada kepemilikan berkas yang tidak tertata.
Race Condition TOCTOU #
TOCTOU (time-of-check to time-of-use) adalah keluarga celah di mana keadaan berkas berubah di antara saat diperiksa dan saat dipakai. Pada filesystem, skenarionya khas: kode memeriksa is_file($path), lalu di mikrodetik sebelum file_get_contents($path) berjalan, penyerang mengganti berkas itu dengan symlink yang menunjuk ke /etc/passwd — pemeriksaan lulus, pembacaan mengikuti tautan:
<?php
$file = '/var/www/app/data/laporan.txt';
// Pola rentan TOCTOU: jendela terbuka di antara cek dan pakai
if (is_file($file)) {
$isi = file_get_contents($file);
}
// Lebih baik: buka sekali, lalu bekerja pada handle yang sama
$handle = fopen($file, 'rb');
if ($handle !== false) {
$isi = stream_get_contents($handle);
fclose($handle);
}Pola fopen() di atas menyempitkan jendela race karena pemeriksaan dan penggunaan kini menunjuk handle yang sama — berkas yang dibuka adalah berkas yang dibaca. Menyempitkan, bukan menutup total: pertahanan paling kuat tetap kombinasi dari direktori yang tidak bisa ditulis penyerang (permission dan kepemilikan yang benar), realpath() sebelum membuka, dan API yang dirancang untuk kasus ini — pada upload, pasangan is_uploaded_file() dan move_uploaded_file() menolak berkas yang bukan hasil upload sungguhan, menutup celah penggantian di tengah jalan.
Ringkasan #
Keamanan filesystem berdiri di atas beberapa prinsip yang saling menguatkan: masukan pengguna tidak pernah menyentuh path tanpa normalisasi dan pemeriksaan containment lewat realpath(), nama upload tidak pernah dipercaya sebagai nama penyimpanan, perintah shell tidak menerima argumen tanpa escapeshellarg() — atau lebih baik dihindari, permission mengikuti least privilege, dan open_basedir menjadi pagar terakhir di level konfigurasi. Pola validasi yang sama berlaku untuk semua masukan pengguna, dan kita akan menemainya kembali dalam konteks form pada section form handling dan validation.